diff --git a/components/pet/farm/RemoveByRarityPanel.tsx b/components/pet/farm/RemoveByRarityPanel.tsx new file mode 100644 index 0000000..0f8891f --- /dev/null +++ b/components/pet/farm/RemoveByRarityPanel.tsx @@ -0,0 +1,137 @@ +// ============================================================ +// AI-GENERATED FILE +// Created: 2026-06-15 +// Purpose: Bulk "remove all plants of a rarity" panel. Lists each +// rarity present among LIVE plots with its count + estimated +// 50% refund, and a two-step confirm before firing the +// server-side `removeByRarity` action (one atomic txn). +// Dead plants are excluded here (parity with single Remove / +// uprootPlot — those use Clear), so counts match what the +// server will actually uproot. +// ============================================================ +import { useMemo, useState } from "react" +import PixelButton from "@/components/pet/ui/PixelButton" +import PixelBadge from "@/components/pet/ui/PixelBadge" +import GoldDisplay from "@/components/pet/ui/GoldDisplay" +import type { FarmPlot } from "./FarmScene" + +// Most-valuable first, so the riskiest removals read top-down. +const RARITY_ORDER = ["LEGENDARY", "EPIC", "RARE", "UNCOMMON", "COMMON"] as const + +const rarityBorderColors: Record = { + COMMON: "#6a7080", + UNCOMMON: "#4080f0", + RARE: "#e04040", + EPIC: "#f0c040", + LEGENDARY: "#d060f0", +} + +interface RemoveByRarityPanelProps { + plots: FarmPlot[] + onRemove: (rarity: string) => Promise + onCancel: () => void +} + +export default function RemoveByRarityPanel({ plots, onRemove, onCancel }: RemoveByRarityPanelProps) { + const [pending, setPending] = useState(null) + const [acting, setActing] = useState(null) + + // Group live, planted plots by rarity. Refund mirrors the server: + // floor(goldInvested / 2) per plot, summed. + const groups = useMemo(() => { + const acc: Record = {} + for (const p of plots) { + if (p.empty || p.dead || !p.seed) continue + const r = p.rarity || "COMMON" + const g = acc[r] || (acc[r] = { count: 0, refund: 0 }) + g.count++ + g.refund += Math.floor((p.goldInvested || 0) / 2) + } + return RARITY_ORDER.filter((r) => acc[r]).map((r) => ({ rarity: r, ...acc[r] })) + }, [plots]) + + async function confirm(rarity: string) { + setActing(rarity) + try { + await onRemove(rarity) + } finally { + setActing(null) + setPending(null) + } + } + + return ( +
+
+
+ Remove by Rarity + +
+ +

+ Uproots every live plant of the chosen rarity for a 50% gold refund. Dead plants aren't touched + (use Clear Dead). This can't be undone. +

+ + {groups.length === 0 ? ( +

+ No live plants to remove. +

+ ) : ( +
+ {groups.map(({ rarity, count, refund }) => { + const bc = rarityBorderColors[rarity] || "#3a4a6c" + const isPending = pending === rarity + const isActing = acting === rarity + return ( +
+
+ + + {count} plant{count > 1 ? "s" : ""} + + {refund > 0 && ( + + ~ back + + )} +
+ + {isPending ? ( +
+ confirm(rarity)}> + Confirm + + setPending(null)}> + Cancel + +
+ ) : ( + setPending(rarity)} + > + Remove all + + )} +
+ ) + })} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/lib/pet/farmService.ts b/lib/pet/farmService.ts index 7f4fa36..c91b26c 100644 --- a/lib/pet/farmService.ts +++ b/lib/pet/farmService.ts @@ -32,6 +32,8 @@ const TIER_DEATH_TIMER_HOURS: Record = { } const GROWTH_PER_TEXT_MESSAGE = 2.0 +const VALID_RARITIES = ["COMMON", "UNCOMMON", "RARE", "EPIC", "LEGENDARY"] + export function isWatered(lastWatered: Date | null, waterIntervalHours: number, tier = "NONE"): boolean { if (!lastWatered) return false const elapsed = (Date.now() - lastWatered.getTime()) / 1000 @@ -423,3 +425,57 @@ export async function harvestAll(userId: bigint) { details, materialDrops: allDrops, } } + +/** + * Uproot every LIVE, planted plot of a given rarity in ONE atomic statement, + * refunding 50% of each plot's gold_invested (parity with uprootPlot). Dead + * plants are excluded — like single uproot, they must be `clear`ed instead. + * + * The CTE captures each plot's pre-clear gold_invested under `FOR UPDATE`, so + * the refund is summed from exactly the rows we cleared — no read-then-write + * window, no refund drift if the user mutates the farm concurrently. Clear + + * refund + ledger row all commit together (or not at all). + */ +export async function removeByRarity(userId: bigint, rarity: string) { + const target = String(rarity ?? "").toUpperCase() + if (!VALID_RARITIES.includes(target)) { + throw new PetServiceError(400, "bad_rarity", `Invalid rarity. Use one of: ${VALID_RARITIES.join(", ")}`) + } + + return prisma.$transaction(async (tx) => { + const removed = await tx.$queryRaw>` + WITH victims AS ( + SELECT plot_id, gold_invested + FROM lg_user_farm + WHERE userid = ${userId} AND seed_id IS NOT NULL AND dead = false AND rarity = ${target} + FOR UPDATE + ), cleared AS ( + UPDATE lg_user_farm f + SET seed_id = NULL, planted_at = NULL, last_watered = NULL, growth_stage = 0, + dead = false, growth_points = 0, gold_invested = 0, + voice_minutes_earned = 0, messages_earned = 0, rarity = 'COMMON' + FROM victims v + WHERE f.userid = ${userId} AND f.plot_id = v.plot_id + RETURNING f.plot_id + ) + SELECT plot_id, gold_invested FROM victims ORDER BY plot_id` + + const plotIds = removed.map((r) => r.plot_id) + const totalRefund = removed.reduce((sum, r) => sum + Math.floor((r.gold_invested || 0) / 2), 0) + + if (totalRefund > 0) { + await tx.user_config.update({ + where: { userid: userId }, + data: { gold: { increment: totalRefund } }, + }) + await tx.lg_gold_transactions.create({ + data: { + transaction_type: "FARM_HARVEST", actorid: userId, to_account: userId, + amount: totalRefund, description: `Removed ${plotIds.length} ${target} plant(s) (50% refund)`, + }, + }) + } + + return { success: true, action: "removedByRarity", rarity: target, count: plotIds.length, totalRefund, plotIds } + }) +} diff --git a/pages/api/anki/pet/farm.ts b/pages/api/anki/pet/farm.ts index 6b06d6c..7c414fa 100644 --- a/pages/api/anki/pet/farm.ts +++ b/pages/api/anki/pet/farm.ts @@ -25,6 +25,7 @@ import { waterAll, plantAll, harvestAll, + removeByRarity, } from "@/lib/pet/farmService" export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -52,10 +53,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.setHeader("Retry-After", String(rl.retryAfter)) return res.status(429).json({ error: "rate_limited", message: "Too many requests — slow down." }) } - const { action, plotId, seedId } = (req.body || {}) as { + const { action, plotId, seedId, rarity } = (req.body || {}) as { action?: string plotId?: number seedId?: number + rarity?: string } try { let out @@ -68,6 +70,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) case "waterAll": out = await waterAll(ctx.userId); break case "plantAll": out = await plantAll(ctx.userId, seedId as number); break case "harvestAll": out = await harvestAll(ctx.userId); break + case "removeByRarity": out = await removeByRarity(ctx.userId, rarity as string); break default: return res.status(400).json({ error: "bad_action", message: "Unknown farm action" }) } diff --git a/pages/api/pet/farm.ts b/pages/api/pet/farm.ts index 68a83cd..3599b3c 100644 --- a/pages/api/pet/farm.ts +++ b/pages/api/pet/farm.ts @@ -28,6 +28,7 @@ import { waterAll, plantAll, harvestAll, + removeByRarity, } from "@/lib/pet/farmService" export default apiHandler({ @@ -99,7 +100,7 @@ export default apiHandler({ const auth = await requireAuth(req, res) if (!auth) return const userId = BigInt(auth.discordId) - const { action, plotId, seedId } = req.body + const { action, plotId, seedId, rarity } = req.body try { switch (action) { @@ -119,6 +120,8 @@ export default apiHandler({ return res.status(200).json(await plantAll(userId, seedId)) case "harvestAll": return res.status(200).json(await harvestAll(userId)) + case "removeByRarity": + return res.status(200).json(await removeByRarity(userId, rarity)) case "toggleFullscreen": { const pet = await prisma.lg_pets.findUnique({ where: { userid: userId }, diff --git a/pages/pet/farm.tsx b/pages/pet/farm.tsx index 2666f02..8e83171 100644 --- a/pages/pet/farm.tsx +++ b/pages/pet/farm.tsx @@ -27,6 +27,7 @@ const PlotDetail = dynamic(() => import("@/components/pet/farm/PlotDetail"), { s const SeedSelector = dynamic(() => import("@/components/pet/farm/SeedSelector"), { ssr: false }) const HarvestModal = dynamic(() => import("@/components/pet/farm/HarvestModal"), { ssr: false }) const FarmHistory = dynamic(() => import("@/components/pet/farm/FarmHistory"), { ssr: false }) +const RemoveByRarityPanel = dynamic(() => import("@/components/pet/farm/RemoveByRarityPanel"), { ssr: false }) // --- AI-MODIFIED (2026-03-17) --- // Purpose: Use shared GameboyFrame from components/pet/ (supports skin + width props) const GameboyFrame = dynamic(() => import("@/components/pet/GameboyFrame"), { ssr: false }) @@ -85,6 +86,10 @@ export default function FarmPage() { // mode (no plot id, costs multiplied by empty-plot count). const [showBulkPlanter, setShowBulkPlanter] = useState(false) // --- END AI-MODIFIED --- + // --- AI-MODIFIED (2026-06-15) --- + // Purpose: Bulk "remove by rarity" panel toggle. + const [showRemoveByRarity, setShowRemoveByRarity] = useState(false) + // --- END AI-MODIFIED --- const [justWatered, setJustWatered] = useState(false) const [harvestResult, setHarvestResult] = useState(null) const [isFullscreen, setIsFullscreen] = useState(false) @@ -137,6 +142,7 @@ export default function FarmPage() { return newVal }) setShowSeedSelector(false) + setShowRemoveByRarity(false) }, []) // --- END AI-MODIFIED --- @@ -284,6 +290,30 @@ export default function FarmPage() { }, [mutate, showMessage]) // --- END AI-MODIFIED --- + // --- AI-MODIFIED (2026-06-15) --- + // Purpose: Bulk-remove every live plant of a rarity in one atomic call + // (server-side removeByRarity). Closes the panel on success. + const handleRemoveByRarity = useCallback(async (rarity: string) => { + try { + const res = await fetch("/api/pet/farm", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "removeByRarity", rarity }), + }) + const body = await res.json() + if (!res.ok) { showMessage(body.error || "Remove failed", "error"); return } + if (body.count === 0) { + showMessage(`No ${rarity} plants to remove`, "error") + } else { + showMessage(`Removed ${body.count} ${rarity} plant${body.count > 1 ? "s" : ""}. Refunded ${body.totalRefund}G`, "success") + } + setShowRemoveByRarity(false) + setSelectedPlot(null) + mutate() + invalidate("/api/pet/overview") + } catch { showMessage("Network error", "error") } + }, [mutate, showMessage]) + // --- END AI-MODIFIED --- + // --- AI-MODIFIED (2026-03-16) --- // Purpose: Fullscreen toggle syncs to database via API instead of localStorage only const toggleFullscreen = useCallback(async () => { @@ -452,6 +482,26 @@ export default function FarmPage() { color="#e04040" /> )} + {/* --- AI-MODIFIED (2026-06-15) --- */} + {/* Purpose: "Remove by Rarity" -- opens a panel to bulk-uproot + every live plant of a chosen rarity (50% refund) via the + server-side removeByRarity action. Only shown when live + plants exist. */} + {hasPlanted &&
} + {hasPlanted && ( + { + setSelectedPlot(null) + setShowSeedSelector(false) + setShowBulkPlanter(false) + setShowRemoveByRarity(true) + }} + color="#e04040" + /> + )} + {/* --- END AI-MODIFIED --- */} {(hasPlanted || hasHarvestable || hasDead || hasEmpty) &&
} - {selectedPlotData && !showSeedSelector && !showBulkPlanter && ( + {/* --- AI-MODIFIED (2026-06-15) --- */} + {/* Purpose: Bulk remove-by-rarity panel (takes over the detail slot). */} + {showRemoveByRarity && ( + setShowRemoveByRarity(false)} + /> + )} + {/* --- END AI-MODIFIED --- */} + {selectedPlotData && !showSeedSelector && !showBulkPlanter && !showRemoveByRarity && (