Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions components/pet/farm/RemoveByRarityPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
COMMON: "#6a7080",
UNCOMMON: "#4080f0",
RARE: "#e04040",
EPIC: "#f0c040",
LEGENDARY: "#d060f0",
}

interface RemoveByRarityPanelProps {
plots: FarmPlot[]
onRemove: (rarity: string) => Promise<void>
onCancel: () => void
}

export default function RemoveByRarityPanel({ plots, onRemove, onCancel }: RemoveByRarityPanelProps) {
const [pending, setPending] = useState<string | null>(null)
const [acting, setActing] = useState<string | null>(null)

// Group live, planted plots by rarity. Refund mirrors the server:
// floor(goldInvested / 2) per plot, summed.
const groups = useMemo(() => {
const acc: Record<string, { count: number; refund: number }> = {}
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 (
<div className="border-[3px] border-[#7a2a2a] p-[3px]" style={{ boxShadow: "3px 3px 0 #060810" }}>
<div className="border-2 border-[#e04040]/40 bg-[#0c1020] p-4 space-y-3">
<div className="flex items-center justify-between pb-2 border-b-2 border-[#1a2a3c]">
<span className="font-pixel text-base text-[var(--pet-text,#e2e8f0)]">Remove by Rarity</span>
<button
onClick={onCancel}
className="font-pixel text-[12px] text-[var(--pet-text-dim,#8899aa)] hover:text-[#c0d0e0]"
>
Close
</button>
</div>

<p className="font-pixel text-[11px] text-[var(--pet-text-dim,#8899aa)] leading-relaxed">
Uproots every live plant of the chosen rarity for a 50% gold refund. Dead plants aren&apos;t touched
(use Clear Dead). This can&apos;t be undone.
</p>

{groups.length === 0 ? (
<p className="font-pixel text-[12px] text-[var(--pet-text-dim,#8899aa)] py-2 text-center">
No live plants to remove.
</p>
) : (
<div className="space-y-2">
{groups.map(({ rarity, count, refund }) => {
const bc = rarityBorderColors[rarity] || "#3a4a6c"
const isPending = pending === rarity
const isActing = acting === rarity
return (
<div
key={rarity}
className="flex items-center justify-between gap-3 border-2 bg-[#080c18] px-3 py-2"
style={{ borderColor: `${bc}60` }}
>
<div className="flex items-center gap-2 min-w-0">
<PixelBadge rarity={rarity} />
<span className="font-pixel text-[13px] text-[var(--pet-text,#e2e8f0)]">
{count} plant{count > 1 ? "s" : ""}
</span>
{refund > 0 && (
<span className="font-pixel text-[11px] text-[var(--pet-text-dim,#8899aa)] flex items-center gap-1">
~<GoldDisplay amount={refund} size="sm" /> back
</span>
)}
</div>

{isPending ? (
<div className="flex items-center gap-1.5 flex-shrink-0">
<PixelButton variant="danger" size="sm" loading={isActing} onClick={() => confirm(rarity)}>
Confirm
</PixelButton>
<PixelButton variant="ghost" size="sm" disabled={isActing} onClick={() => setPending(null)}>
Cancel
</PixelButton>
</div>
) : (
<PixelButton
variant="danger"
size="sm"
className="flex-shrink-0"
disabled={acting !== null}
onClick={() => setPending(rarity)}
>
Remove all
</PixelButton>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}
56 changes: 56 additions & 0 deletions lib/pet/farmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const TIER_DEATH_TIMER_HOURS: Record<string, number | null> = {
}
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
Expand Down Expand Up @@ -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<Array<{ plot_id: number; gold_invested: number }>>`
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 }
})
}
5 changes: 4 additions & 1 deletion pages/api/anki/pet/farm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
waterAll,
plantAll,
harvestAll,
removeByRarity,
} from "@/lib/pet/farmService"

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
Expand Down Expand Up @@ -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
Expand All @@ -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" })
}
Expand Down
5 changes: 4 additions & 1 deletion pages/api/pet/farm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
waterAll,
plantAll,
harvestAll,
removeByRarity,
} from "@/lib/pet/farmService"

export default apiHandler({
Expand Down Expand Up @@ -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) {
Expand All @@ -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 },
Expand Down
62 changes: 61 additions & 1 deletion pages/pet/farm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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<HarvestResult | null>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
Expand Down Expand Up @@ -137,6 +142,7 @@ export default function FarmPage() {
return newVal
})
setShowSeedSelector(false)
setShowRemoveByRarity(false)
}, [])
// --- END AI-MODIFIED ---

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 && <div className="w-px h-10 bg-[#1a2a3c]" />}
{hasPlanted && (
<ToolbarButton
iconUrl={getUiIconUrl("liongotchi_greenpot")}
label="Remove by Rarity"
onClick={() => {
setSelectedPlot(null)
setShowSeedSelector(false)
setShowBulkPlanter(false)
setShowRemoveByRarity(true)
}}
color="#e04040"
/>
)}
{/* --- END AI-MODIFIED --- */}
{(hasPlanted || hasHarvestable || hasDead || hasEmpty) && <div className="w-px h-10 bg-[#1a2a3c]" />}
<ToolbarButton
iconUrl={getUiIconUrl(isFullscreen ? "liongotchi_heart" : "liongotchi_greenpot")}
Expand All @@ -468,7 +518,17 @@ export default function FarmPage() {
{/* --- AI-MODIFIED (2026-03-22) --- */}
{/* Purpose: Wrap PlotDetail/SeedSelector in a div with ref for auto-scroll */}
<div ref={plotDetailRef}>
{selectedPlotData && !showSeedSelector && !showBulkPlanter && (
{/* --- AI-MODIFIED (2026-06-15) --- */}
{/* Purpose: Bulk remove-by-rarity panel (takes over the detail slot). */}
{showRemoveByRarity && (
<RemoveByRarityPanel
plots={data.plots}
onRemove={handleRemoveByRarity}
onCancel={() => setShowRemoveByRarity(false)}
/>
)}
{/* --- END AI-MODIFIED --- */}
{selectedPlotData && !showSeedSelector && !showBulkPlanter && !showRemoveByRarity && (
<PlotDetail
plot={selectedPlotData}
onAction={handleAction}
Expand Down