Skip to content

Commit 5dd809a

Browse files
dengzhaofunclaude
andauthored
Add weighted lottery/gacha system with admin dashboard (#11)
Full lottery module: pools, tiers, prizes, pity rules, pull logs. Supports flat mode (spin wheel), tiered mode (gacha with SSR/SR/R), hard/soft pity guarantees, stock limits, rate-up, and item-triggered opening (treasure chest via lotteryPoolId on item definitions). Server: schema, service (CRUD + pull/multiPull), admin routes, client routes, RNG pure functions with unit tests, integration tests. Admin: lottery pages (list/create/detail with inline tier/prize/pity management), sidebar nav entry, item DefinitionForm updated with lottery pool selector. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d959775 commit 5dd809a

37 files changed

Lines changed: 10876 additions & 0 deletions

apps/admin/src/components/AppSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router"
33
import {
44
ArrowLeftRight,
55
CalendarCheck,
6+
Dices,
67
KeyRound,
78
LayoutDashboard,
89
Package,
@@ -27,6 +28,7 @@ const navItems = [
2728
{ title: "Check-in", to: "/check-in" as const, icon: CalendarCheck },
2829
{ title: "Item", to: "/item" as const, icon: Package },
2930
{ title: "Exchange", to: "/exchange" as const, icon: ArrowLeftRight },
31+
{ title: "Lottery", to: "/lottery" as const, icon: Dices },
3032
{ title: "API Keys", to: "/api-keys" as const, icon: KeyRound },
3133
]
3234

apps/admin/src/components/item/DefinitionForm.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { Switch } from "#/components/ui/switch"
1313
import { Label } from "#/components/ui/label"
1414
import { useItemCategories } from "#/hooks/use-item"
15+
import { useLotteryPools } from "#/hooks/use-lottery"
1516
import type { CreateDefinitionInput } from "#/lib/types/item"
1617

1718
interface DefinitionFormProps {
@@ -28,6 +29,7 @@ export function DefinitionForm({
2829
submitLabel = "Create",
2930
}: DefinitionFormProps) {
3031
const { data: categories } = useItemCategories()
32+
const { data: pools } = useLotteryPools()
3133

3234
const form = useForm({
3335
defaultValues: {
@@ -39,6 +41,7 @@ export function DefinitionForm({
3941
stackable: defaultValues?.stackable ?? true,
4042
stackLimit: defaultValues?.stackLimit ?? (null as number | null),
4143
holdLimit: defaultValues?.holdLimit ?? (null as number | null),
44+
lotteryPoolId: (defaultValues as Record<string, unknown>)?.lotteryPoolId as string ?? "",
4245
isActive: defaultValues?.isActive ?? true,
4346
},
4447
onSubmit: async ({ value }) => {
@@ -51,6 +54,7 @@ export function DefinitionForm({
5154
stackable: value.stackable,
5255
stackLimit: value.stackable ? value.stackLimit : null,
5356
holdLimit: value.holdLimit,
57+
lotteryPoolId: value.lotteryPoolId || null,
5458
isActive: value.isActive,
5559
}
5660
await onSubmit(input)
@@ -229,6 +233,33 @@ export function DefinitionForm({
229233
)}
230234
</form.Field>
231235

236+
<form.Field name="lotteryPoolId">
237+
{(field) => (
238+
<div className="space-y-2">
239+
<Label>Lottery Pool</Label>
240+
<Select
241+
value={field.state.value}
242+
onValueChange={(v) => field.handleChange(v === "__none__" ? "" : v)}
243+
>
244+
<SelectTrigger className="w-full">
245+
<SelectValue placeholder="None" />
246+
</SelectTrigger>
247+
<SelectContent>
248+
<SelectItem value="__none__">None</SelectItem>
249+
{pools?.map((pool) => (
250+
<SelectItem key={pool.id} value={pool.id}>
251+
{pool.name}
252+
</SelectItem>
253+
))}
254+
</SelectContent>
255+
</Select>
256+
<p className="text-xs text-muted-foreground">
257+
Link to a lottery pool to make this item openable (e.g. treasure chest).
258+
</p>
259+
</div>
260+
)}
261+
</form.Field>
262+
232263
<form.Field name="isActive">
233264
{(field) => (
234265
<div className="flex items-center gap-3">
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
AlertDialog,
3+
AlertDialogAction,
4+
AlertDialogCancel,
5+
AlertDialogContent,
6+
AlertDialogDescription,
7+
AlertDialogFooter,
8+
AlertDialogHeader,
9+
AlertDialogTitle,
10+
AlertDialogTrigger,
11+
} from "#/components/ui/alert-dialog"
12+
import { Button } from "#/components/ui/button"
13+
import { Trash2 } from "lucide-react"
14+
15+
interface DeleteDialogProps {
16+
name: string
17+
description?: string
18+
onConfirm: () => void
19+
isPending?: boolean
20+
}
21+
22+
export function LotteryDeleteDialog({
23+
name,
24+
description,
25+
onConfirm,
26+
isPending,
27+
}: DeleteDialogProps) {
28+
return (
29+
<AlertDialog>
30+
<AlertDialogTrigger asChild>
31+
<Button variant="destructive" size="sm">
32+
<Trash2 className="size-4" />
33+
Delete
34+
</Button>
35+
</AlertDialogTrigger>
36+
<AlertDialogContent>
37+
<AlertDialogHeader>
38+
<AlertDialogTitle>Delete &ldquo;{name}&rdquo;?</AlertDialogTitle>
39+
<AlertDialogDescription>
40+
{description ??
41+
"This will permanently delete this item and all associated data. This action cannot be undone."}
42+
</AlertDialogDescription>
43+
</AlertDialogHeader>
44+
<AlertDialogFooter>
45+
<AlertDialogCancel>Cancel</AlertDialogCancel>
46+
<AlertDialogAction
47+
onClick={onConfirm}
48+
disabled={isPending}
49+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
50+
>
51+
{isPending ? "Deleting..." : "Delete"}
52+
</AlertDialogAction>
53+
</AlertDialogFooter>
54+
</AlertDialogContent>
55+
</AlertDialog>
56+
)
57+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { useForm } from "@tanstack/react-form"
2+
import { Button } from "#/components/ui/button"
3+
import { Input } from "#/components/ui/input"
4+
import { Switch } from "#/components/ui/switch"
5+
import { Label } from "#/components/ui/label"
6+
import {
7+
Select,
8+
SelectContent,
9+
SelectItem,
10+
SelectTrigger,
11+
SelectValue,
12+
} from "#/components/ui/select"
13+
import type { CreatePityRuleInput } from "#/lib/types/lottery"
14+
import type { LotteryTier } from "#/lib/types/lottery"
15+
16+
interface PityRuleFormProps {
17+
tiers: LotteryTier[]
18+
defaultValues?: Partial<CreatePityRuleInput>
19+
onSubmit: (values: CreatePityRuleInput) => void | Promise<void>
20+
onCancel?: () => void
21+
isPending?: boolean
22+
submitLabel?: string
23+
disableGuaranteeTier?: boolean
24+
}
25+
26+
export function PityRuleForm({
27+
tiers,
28+
defaultValues,
29+
onSubmit,
30+
onCancel,
31+
isPending,
32+
submitLabel = "Create",
33+
disableGuaranteeTier,
34+
}: PityRuleFormProps) {
35+
const form = useForm({
36+
defaultValues: {
37+
guaranteeTierId: defaultValues?.guaranteeTierId ?? "",
38+
hardPityThreshold: defaultValues?.hardPityThreshold ?? 90,
39+
softPityStartAt: defaultValues?.softPityStartAt ?? (null as number | null),
40+
softPityWeightIncrement: defaultValues?.softPityWeightIncrement ?? (null as number | null),
41+
isActive: defaultValues?.isActive ?? true,
42+
},
43+
onSubmit: async ({ value }) => {
44+
const input: CreatePityRuleInput = {
45+
guaranteeTierId: value.guaranteeTierId,
46+
hardPityThreshold: value.hardPityThreshold,
47+
softPityStartAt: value.softPityStartAt,
48+
softPityWeightIncrement: value.softPityWeightIncrement,
49+
isActive: value.isActive,
50+
}
51+
await onSubmit(input)
52+
},
53+
})
54+
55+
return (
56+
<form
57+
onSubmit={(e) => {
58+
e.preventDefault()
59+
e.stopPropagation()
60+
form.handleSubmit()
61+
}}
62+
className="space-y-4"
63+
>
64+
<div className="grid gap-4 sm:grid-cols-2">
65+
<form.Field
66+
name="guaranteeTierId"
67+
validators={{
68+
onChange: ({ value }) =>
69+
!value ? "Must select a tier" : undefined,
70+
}}
71+
>
72+
{(field) => (
73+
<div className="space-y-2">
74+
<Label>Guarantee Tier *</Label>
75+
<Select
76+
value={field.state.value}
77+
onValueChange={field.handleChange}
78+
disabled={disableGuaranteeTier}
79+
>
80+
<SelectTrigger className="w-full">
81+
<SelectValue placeholder="Select tier..." />
82+
</SelectTrigger>
83+
<SelectContent>
84+
{tiers.map((tier) => (
85+
<SelectItem key={tier.id} value={tier.id}>
86+
{tier.name}
87+
</SelectItem>
88+
))}
89+
</SelectContent>
90+
</Select>
91+
{field.state.meta.errors.length > 0 && (
92+
<p className="text-sm text-destructive">{field.state.meta.errors[0]}</p>
93+
)}
94+
</div>
95+
)}
96+
</form.Field>
97+
98+
<form.Field
99+
name="hardPityThreshold"
100+
validators={{
101+
onChange: ({ value }) =>
102+
value <= 0 ? "Must be positive" : undefined,
103+
}}
104+
>
105+
{(field) => (
106+
<div className="space-y-2">
107+
<Label htmlFor={field.name}>Hard Pity Threshold *</Label>
108+
<Input
109+
id={field.name}
110+
type="number"
111+
min={1}
112+
value={field.state.value}
113+
onBlur={field.handleBlur}
114+
onChange={(e) => field.handleChange(Number(e.target.value))}
115+
placeholder="e.g. 90"
116+
/>
117+
<p className="text-xs text-muted-foreground">
118+
Guaranteed after this many pulls without the tier.
119+
</p>
120+
{field.state.meta.errors.length > 0 && (
121+
<p className="text-sm text-destructive">{field.state.meta.errors[0]}</p>
122+
)}
123+
</div>
124+
)}
125+
</form.Field>
126+
127+
<form.Field name="softPityStartAt">
128+
{(field) => (
129+
<div className="space-y-2">
130+
<Label htmlFor={field.name}>Soft Pity Start</Label>
131+
<Input
132+
id={field.name}
133+
type="number"
134+
min={1}
135+
value={field.state.value ?? ""}
136+
onBlur={field.handleBlur}
137+
onChange={(e) =>
138+
field.handleChange(e.target.value ? Number(e.target.value) : null)
139+
}
140+
placeholder="e.g. 74"
141+
/>
142+
<p className="text-xs text-muted-foreground">
143+
Start boosting weight after this many pulls.
144+
</p>
145+
</div>
146+
)}
147+
</form.Field>
148+
149+
<form.Field name="softPityWeightIncrement">
150+
{(field) => (
151+
<div className="space-y-2">
152+
<Label htmlFor={field.name}>Weight Increment</Label>
153+
<Input
154+
id={field.name}
155+
type="number"
156+
min={1}
157+
value={field.state.value ?? ""}
158+
onBlur={field.handleBlur}
159+
onChange={(e) =>
160+
field.handleChange(e.target.value ? Number(e.target.value) : null)
161+
}
162+
placeholder="e.g. 60"
163+
/>
164+
<p className="text-xs text-muted-foreground">
165+
Extra weight added per pull after soft pity starts.
166+
</p>
167+
</div>
168+
)}
169+
</form.Field>
170+
</div>
171+
172+
<form.Field name="isActive">
173+
{(field) => (
174+
<div className="flex items-center gap-3">
175+
<Switch
176+
id={field.name}
177+
checked={field.state.value}
178+
onCheckedChange={(checked) => field.handleChange(checked === true)}
179+
/>
180+
<Label htmlFor={field.name}>Active</Label>
181+
</div>
182+
)}
183+
</form.Field>
184+
185+
<div className="flex items-center gap-2">
186+
<form.Subscribe selector={(s) => s.canSubmit}>
187+
{(canSubmit) => (
188+
<Button type="submit" size="sm" disabled={!canSubmit || isPending}>
189+
{isPending ? "Saving..." : submitLabel}
190+
</Button>
191+
)}
192+
</form.Subscribe>
193+
{onCancel && (
194+
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
195+
Cancel
196+
</Button>
197+
)}
198+
</div>
199+
</form>
200+
)
201+
}

0 commit comments

Comments
 (0)