TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { useRouter, useSearchParams } from "next/navigation";6import { CheckCircle2, FlaskConical, Play, XCircle } from "lucide-react";7import { BET_LEVELS } from "@spinza/shared";8import { Button, Progress, Spinner } from "@/components/ui";9import { api, ApiClientError } from "@/lib/api";10import { toast } from "@/lib/store";11import { cn, timeAgo } from "@/lib/utils";12import { useAdminQuery } from "@/components/admin/use-query";13import type { GamesResponse, SimulationRunRow, SimulatorRunResponse, SimulatorRunsResponse } from "@/components/admin/types";14import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, DenseSelect, DenseInput, FieldLabel, Toggle, SegmentedControl, Pill, PassFail, InlineError, EmptyState, LifecyclePill, Mono } from "@/components/admin/primitives";15import { SimulationResults } from "@/components/admin/simulation-results";16import { dateTime, describeError, durationMs, int, num, pct, signedPct } from "@/components/admin/format";1718const PRESETS = [10_000, 100_000, 1_000_000, 10_000_000] as const;19const POLL_MS = 1500;2021export default function AdminSimulatorPage() {22 return (23 <React.Suspense fallback={<PageHeader title="Game Simulator" />}>24 <Simulator />25 </React.Suspense>26 );27}2829function Simulator() {30 const params = useSearchParams();31 const router = useRouter();32 const games = useAdminQuery<GamesResponse>("/api/admin/games");33 const runs = useAdminQuery<SimulatorRunsResponse>("/api/admin/simulator/runs?limit=20");3435 const initialSlug = params.get("slug") ?? "";36 const initialRun = params.get("run");37 const [slug, setSlug] = React.useState(initialSlug);38 const [spins, setSpins] = React.useState<number>(100_000);39 const [customSpins, setCustomSpins] = React.useState("");40 const [bet, setBet] = React.useState<number>(100);41 const [certify, setCertify] = React.useState(params.get("certify") === "1");42 const [starting, setStarting] = React.useState(false);43 const [startError, setStartError] = React.useState<string | null>(null);4445 const [activeRunId, setActiveRunId] = React.useState<string | null>(initialRun);46 const [run, setRun] = React.useState<SimulationRunRow | null>(null);47 const [runError, setRunError] = React.useState<string | null>(null);4849 // Default to the first game once the library loads (when no slug was preselected).50 const firstSlug = games.data?.games[0]?.slug ?? "";51 const effectiveSlug = slug || firstSlug;52 const selected = games.data?.games.find((g) => g.slug === effectiveSlug) ?? null;5354 const effectiveSpins = customSpins ? Number(customSpins) : spins;55 const spinsOk = Number.isInteger(effectiveSpins) && effectiveSpins >= 1000 && effectiveSpins <= 10_000_000;5657 /* ---- polling ---- */58 const refreshRuns = runs.refresh;59 React.useEffect(() => {60 if (!activeRunId) return;61 let cancelled = false;62 let timer: ReturnType<typeof setTimeout> | null = null;63 const tick = async () => {64 try {65 const r = await api<SimulatorRunResponse>(`/api/admin/simulator/runs/${activeRunId}`);66 if (cancelled) return;67 setRun(r.run);68 setRunError(null);69 if (r.run.status === "running") timer = setTimeout(tick, POLL_MS);70 else void refreshRuns();71 } catch (e) {72 if (cancelled) return;73 setRunError(describeError(e));74 if (!(e instanceof ApiClientError && (e.status === 404 || e.status === 401))) timer = setTimeout(tick, POLL_MS * 2);75 }76 };77 void tick();78 return () => {79 cancelled = true;80 if (timer) clearTimeout(timer);81 };82 }, [activeRunId, refreshRuns]);8384 async function start() {85 if (!effectiveSlug || !spinsOk) return;86 setStarting(true);87 setStartError(null);88 try {89 const r = await api<{ runId: string }>("/api/admin/simulator/run", { json: { slug: effectiveSlug, spins: effectiveSpins, bet, certify } });90 setRun(null);91 setActiveRunId(r.runId);92 router.replace(`/admin/simulator?slug=${effectiveSlug}&run=${r.runId}`);93 toast({ title: "Simulation started", description: `${selected?.name ?? effectiveSlug} · ${int(effectiveSpins)} spins${certify ? " · certify" : ""}` });94 void runs.refresh();95 } catch (e) {96 const msg = e instanceof ApiClientError && e.code === "BUSY" ? e.message : describeError(e);97 setStartError(msg);98 } finally {99 setStarting(false);100 }101 }102103 function openRun(r: SimulationRunRow) {104 setRun(null);105 setRunError(null);106 setActiveRunId(r.id);107 if (r.gameSlug !== slug) setSlug(r.gameSlug);108 router.replace(`/admin/simulator?slug=${r.gameSlug}&run=${r.id}`);109 }110111 const busy = run?.status === "running";112113 return (114 <>115 <PageHeader title="Game Simulator" description="Run Monte-Carlo simulations of any game definition in worker threads, watch RTP converge and issue internal certifications." actions={<RefreshButton onClick={() => void runs.refresh()} loading={runs.refreshing} />} />116117 <div className="grid gap-4 xl:grid-cols-[360px_1fr]">118 {/* Left column: configuration + recent runs */}119 <div className="space-y-4">120 <Panel title="Configuration" description="Spins ≥ 1,000 · up to 10M">121 {games.error && !games.data ? (122 <ErrorState error={games.error} onRetry={() => void games.refresh()} />123 ) : (124 <div className="space-y-4">125 <div>126 <FieldLabel hint={selected ? <LifecyclePill lifecycle={selected.lifecycle} /> : undefined}>Game</FieldLabel>127 <DenseSelect className="w-full" value={effectiveSlug} onChange={(e) => setSlug(e.target.value)} disabled={!games.data} aria-label="Game">128 {!games.data ? <option>Loading…</option> : null}129 {games.data?.games.map((g) => (130 <option key={g.slug} value={g.slug}>131 {g.name} · v{g.version}132 </option>133 ))}134 </DenseSelect>135 {selected ? (136 <div className="mt-1.5 flex flex-wrap gap-x-3 text-[12px] text-fg-3">137 <span>target RTP {pct(selected.rtp)}</span>138 <span>payScale {selected.payScale?.toFixed(4) ?? "—"}</span>139 <Link href={`/admin/games/${selected.slug}`} className="text-fg-3 underline-offset-2 hover:text-fg hover:underline">140 details →141 </Link>142 </div>143 ) : null}144 </div>145146 <div>147 <FieldLabel hint={spinsOk ? `${int(effectiveSpins)} spins` : "1,000 – 10,000,000"}>Spins</FieldLabel>148 <SegmentedControl149 className="w-full [&>button]:flex-1"150 value={customSpins ? 0 : spins}151 onChange={(v) => {152 setSpins(v);153 setCustomSpins("");154 }}155 items={PRESETS.map((p) => ({ value: p, label: p >= 1_000_000 ? `${p / 1_000_000}M` : `${p / 1000}K` }))}156 />157 <DenseInput className="mt-2 font-mono" inputMode="numeric" placeholder="Custom spin count" value={customSpins} onChange={(e) => setCustomSpins(e.target.value.replace(/\D/g, ""))} aria-label="Custom spin count" />158 </div>159160 <div className="grid grid-cols-2 gap-3">161 <div>162 <FieldLabel>Bet (SC)</FieldLabel>163 <DenseSelect className="w-full" value={bet} onChange={(e) => setBet(Number(e.target.value))} aria-label="Bet">164 {BET_LEVELS.map((b) => (165 <option key={b} value={b}>166 {b.toLocaleString("en-US")} SC167 </option>168 ))}169 </DenseSelect>170 </div>171 <div>172 <FieldLabel>Certify</FieldLabel>173 <div className="flex h-9 items-center gap-2">174 <Toggle checked={certify} onChange={setCertify} label="Produce a certification report" />175 <span className="text-[12px] text-fg-3">{certify ? "report + checks" : "stats only"}</span>176 </div>177 </div>178 </div>179 {certify && effectiveSpins < 1_000_000 ? <p className="text-[12px] text-[#ffc46b]">A production certification needs 1M spins; smaller runs relax the minimum-spins rule and are for exploration only.</p> : null}180181 {startError ? <InlineError>{startError}</InlineError> : null}182183 <Button variant="accent" className="w-full" onClick={() => void start()} loading={starting} disabled={!effectiveSlug || !spinsOk || busy}>184 <Play className="h-4 w-4" /> {busy ? "Simulation running…" : "Run simulation"}185 </Button>186 </div>187 )}188 </Panel>189190 <Panel title="Recent runs" description="Click to load a result" padded={false}>191 {runs.error && !runs.data ? (192 <div className="p-3">193 <ErrorState error={runs.error} onRetry={() => void runs.refresh()} />194 </div>195 ) : !runs.data ? (196 <TableSkeleton rows={6} cols={3} />197 ) : runs.data.runs.length === 0 ? (198 <EmptyState title="No runs yet" description="Configure a simulation above and press Run." />199 ) : (200 <ul className={cn("divide-y divide-line/60", runs.stale && "opacity-60")}>201 {runs.data.runs.map((r) => {202 const active = r.id === activeRunId;203 return (204 <li key={r.id}>205 <button type="button" onClick={() => openRun(r)} className={cn("flex w-full items-center gap-3 px-3 py-2 text-left text-[12px] transition-colors hover:bg-surface-2 focus-ring", active && "bg-surface-2")}>206 <RunStatusIcon status={r.status} />207 <div className="min-w-0 flex-1">208 <div className="flex items-center gap-2">209 <span className="truncate font-medium text-fg">{r.gameSlug}</span>210 <Mono className="text-fg-4">v{r.gameVersion}</Mono>211 </div>212 <div className="text-fg-3">213 {int(r.spins)} spins · {timeAgo(r.createdAt)}214 {r.status === "running" ? ` · ${Math.round((num(r.progress) / Math.max(1, num(r.spins))) * 100)}%` : ""}215 </div>216 </div>217 </button>218 </li>219 );220 })}221 </ul>222 )}223 </Panel>224 </div>225226 {/* Right column: run status + results */}227 <div className="space-y-4">228 {!activeRunId ? (229 <Panel className="min-h-[320px]">230 <EmptyState title="No simulation selected" description="Pick a game, choose a spin count and press Run — or open a recent run from the list." action={<FlaskConical className="h-5 w-5 text-fg-4" />} />231 </Panel>232 ) : (233 <>234 <RunStatus run={run} error={runError} />235 {run?.status === "done" && run.result?.simulation ? <SimulationResults sim={run.result.simulation} cert={run.result.certification ?? null} /> : null}236 {run?.status === "done" && !run.result?.simulation ? <InlineError>This run finished but stored no result payload.</InlineError> : null}237 </>238 )}239 </div>240 </div>241 </>242 );243}244245function RunStatusIcon({ status }: { status: string }) {246 if (status === "done") return <CheckCircle2 className="h-4 w-4 shrink-0 text-success" />;247 if (status === "failed") return <XCircle className="h-4 w-4 shrink-0 text-danger" />;248 return <Spinner className="h-4 w-4 shrink-0 text-info" />;249}250251function RunStatus({ run, error }: { run: SimulationRunRow | null; error: string | null }) {252 // Wall clock for the elapsed/ETA readout: ticks every second while the run is in progress.253 const [now, setNow] = React.useState<number | null>(null);254 React.useEffect(() => {255 if (run?.status !== "running") return;256 const tick = () => setNow(Date.now());257 const first = setTimeout(tick, 0);258 const t = setInterval(tick, 1000);259 return () => {260 clearTimeout(first);261 clearInterval(t);262 };263 }, [run?.status]);264265 if (!run) {266 return (267 <Panel>268 {error ? (269 <InlineError>{error}</InlineError>270 ) : (271 <div className="flex items-center gap-2 text-[13px] text-fg-3">272 <Spinner className="h-4 w-4" /> Loading run…273 </div>274 )}275 </Panel>276 );277 }278279 const progress = num(run.progress);280 const total = Math.max(1, num(run.spins));281 const ratio = Math.min(1, progress / total);282 const createdMs = new Date(run.createdAt).getTime();283 const elapsedMs = run.finishedAt ? new Date(run.finishedAt).getTime() - createdMs : now !== null ? Math.max(0, now - createdMs) : null;284 const eta = elapsedMs !== null && ratio > 0.02 && run.status === "running" ? (elapsedMs / ratio) * (1 - ratio) : null;285 const sim = run.result?.simulation;286 const cert = run.result?.certification ?? null;287288 return (289 <Panel290 title={291 <span className="inline-flex items-center gap-2">292 {run.gameSlug} <Mono className="text-fg-4">v{run.gameVersion}</Mono>293 <Pill tone={run.status === "done" ? "success" : run.status === "failed" ? "danger" : "info"}>{run.status}</Pill>294 {cert ? <PassFail pass={cert.status === "PASS"} label={`cert ${cert.status}`} /> : null}295 </span>296 }297 description={`Run ${run.id.slice(0, 8)} · started ${dateTime(run.createdAt)}${run.finishedAt ? ` · finished ${dateTime(run.finishedAt)}` : ""}`}298 tone={run.status === "failed" ? "danger" : undefined}299 >300 {run.status === "running" ? (301 <div>302 <div className="mb-2 flex items-baseline justify-between text-[13px]">303 <span className="text-fg-2">304 <span className="font-semibold text-fg tabular">{int(progress)}</span> <span className="text-fg-4">/</span> {int(total)} spins305 </span>306 <span className="tabular text-fg-3">307 {(ratio * 100).toFixed(1)}%{elapsedMs !== null ? ` · ${durationMs(elapsedMs)} elapsed` : ""}{eta !== null ? ` · ~${durationMs(eta)} left` : ""}308 </span>309 </div>310 <Progress value={ratio} max={1} className="h-2" />311 <p className="mt-2 text-[12px] text-fg-4">Polling every 1.5 s. Worker threads keep the API responsive; you can leave this page and re-open the run later.</p>312 </div>313 ) : run.status === "failed" ? (314 <InlineError>{run.error ?? "The simulation failed without an error message."}</InlineError>315 ) : sim ? (316 <div className="flex flex-wrap gap-x-6 gap-y-1 text-[13px] text-fg-2">317 <span>318 <span className="text-fg-4">spins</span> {int(sim.spins)}319 </span>320 <span>321 <span className="text-fg-4">bet</span> {int(sim.bet)} SC322 </span>323 <span>324 <span className="text-fg-4">observed</span> {pct(sim.observedRtp, 3)} <span className="text-fg-4">({signedPct(sim.deviation, 3)})</span>325 </span>326 <span>327 <span className="text-fg-4">duration</span> {durationMs(sim.durationMs)}328 </span>329 </div>330 ) : null}331 </Panel>332 );333}334