"use client"; import * as React from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { CheckCircle2, FlaskConical, Play, XCircle } from "lucide-react"; import { BET_LEVELS } from "@spinza/shared"; import { Button, Progress, Spinner } from "@/components/ui"; import { api, ApiClientError } from "@/lib/api"; import { toast } from "@/lib/store"; import { cn, timeAgo } from "@/lib/utils"; import { useAdminQuery } from "@/components/admin/use-query"; import type { GamesResponse, SimulationRunRow, SimulatorRunResponse, SimulatorRunsResponse } from "@/components/admin/types"; import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, DenseSelect, DenseInput, FieldLabel, Toggle, SegmentedControl, Pill, PassFail, InlineError, EmptyState, LifecyclePill, Mono } from "@/components/admin/primitives"; import { SimulationResults } from "@/components/admin/simulation-results"; import { dateTime, describeError, durationMs, int, num, pct, signedPct } from "@/components/admin/format"; const PRESETS = [10_000, 100_000, 1_000_000, 10_000_000] as const; const POLL_MS = 1500; export default function AdminSimulatorPage() { return ( }> ); } function Simulator() { const params = useSearchParams(); const router = useRouter(); const games = useAdminQuery("/api/admin/games"); const runs = useAdminQuery("/api/admin/simulator/runs?limit=20"); const initialSlug = params.get("slug") ?? ""; const initialRun = params.get("run"); const [slug, setSlug] = React.useState(initialSlug); const [spins, setSpins] = React.useState(100_000); const [customSpins, setCustomSpins] = React.useState(""); const [bet, setBet] = React.useState(100); const [certify, setCertify] = React.useState(params.get("certify") === "1"); const [starting, setStarting] = React.useState(false); const [startError, setStartError] = React.useState(null); const [activeRunId, setActiveRunId] = React.useState(initialRun); const [run, setRun] = React.useState(null); const [runError, setRunError] = React.useState(null); // Default to the first game once the library loads (when no slug was preselected). const firstSlug = games.data?.games[0]?.slug ?? ""; const effectiveSlug = slug || firstSlug; const selected = games.data?.games.find((g) => g.slug === effectiveSlug) ?? null; const effectiveSpins = customSpins ? Number(customSpins) : spins; const spinsOk = Number.isInteger(effectiveSpins) && effectiveSpins >= 1000 && effectiveSpins <= 10_000_000; /* ---- polling ---- */ const refreshRuns = runs.refresh; React.useEffect(() => { if (!activeRunId) return; let cancelled = false; let timer: ReturnType | null = null; const tick = async () => { try { const r = await api(`/api/admin/simulator/runs/${activeRunId}`); if (cancelled) return; setRun(r.run); setRunError(null); if (r.run.status === "running") timer = setTimeout(tick, POLL_MS); else void refreshRuns(); } catch (e) { if (cancelled) return; setRunError(describeError(e)); if (!(e instanceof ApiClientError && (e.status === 404 || e.status === 401))) timer = setTimeout(tick, POLL_MS * 2); } }; void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [activeRunId, refreshRuns]); async function start() { if (!effectiveSlug || !spinsOk) return; setStarting(true); setStartError(null); try { const r = await api<{ runId: string }>("/api/admin/simulator/run", { json: { slug: effectiveSlug, spins: effectiveSpins, bet, certify } }); setRun(null); setActiveRunId(r.runId); router.replace(`/admin/simulator?slug=${effectiveSlug}&run=${r.runId}`); toast({ title: "Simulation started", description: `${selected?.name ?? effectiveSlug} · ${int(effectiveSpins)} spins${certify ? " · certify" : ""}` }); void runs.refresh(); } catch (e) { const msg = e instanceof ApiClientError && e.code === "BUSY" ? e.message : describeError(e); setStartError(msg); } finally { setStarting(false); } } function openRun(r: SimulationRunRow) { setRun(null); setRunError(null); setActiveRunId(r.id); if (r.gameSlug !== slug) setSlug(r.gameSlug); router.replace(`/admin/simulator?slug=${r.gameSlug}&run=${r.id}`); } const busy = run?.status === "running"; return ( <> void runs.refresh()} loading={runs.refreshing} />} />
{/* Left column: configuration + recent runs */}
{games.error && !games.data ? ( void games.refresh()} /> ) : (
: undefined}>Game setSlug(e.target.value)} disabled={!games.data} aria-label="Game"> {!games.data ? : null} {games.data?.games.map((g) => ( ))} {selected ? (
target RTP {pct(selected.rtp)} payScale {selected.payScale?.toFixed(4) ?? "—"} details →
) : null}
Spins { setSpins(v); setCustomSpins(""); }} items={PRESETS.map((p) => ({ value: p, label: p >= 1_000_000 ? `${p / 1_000_000}M` : `${p / 1000}K` }))} /> setCustomSpins(e.target.value.replace(/\D/g, ""))} aria-label="Custom spin count" />
Bet (SC) setBet(Number(e.target.value))} aria-label="Bet"> {BET_LEVELS.map((b) => ( ))}
Certify
{certify ? "report + checks" : "stats only"}
{certify && effectiveSpins < 1_000_000 ?

A production certification needs 1M spins; smaller runs relax the minimum-spins rule and are for exploration only.

: null} {startError ? {startError} : null}
)}
{runs.error && !runs.data ? (
void runs.refresh()} />
) : !runs.data ? ( ) : runs.data.runs.length === 0 ? ( ) : (
    {runs.data.runs.map((r) => { const active = r.id === activeRunId; return (
  • ); })}
)}
{/* Right column: run status + results */}
{!activeRunId ? ( } /> ) : ( <> {run?.status === "done" && run.result?.simulation ? : null} {run?.status === "done" && !run.result?.simulation ? This run finished but stored no result payload. : null} )}
); } function RunStatusIcon({ status }: { status: string }) { if (status === "done") return ; if (status === "failed") return ; return ; } function RunStatus({ run, error }: { run: SimulationRunRow | null; error: string | null }) { // Wall clock for the elapsed/ETA readout: ticks every second while the run is in progress. const [now, setNow] = React.useState(null); React.useEffect(() => { if (run?.status !== "running") return; const tick = () => setNow(Date.now()); const first = setTimeout(tick, 0); const t = setInterval(tick, 1000); return () => { clearTimeout(first); clearInterval(t); }; }, [run?.status]); if (!run) { return ( {error ? ( {error} ) : (
Loading run…
)}
); } const progress = num(run.progress); const total = Math.max(1, num(run.spins)); const ratio = Math.min(1, progress / total); const createdMs = new Date(run.createdAt).getTime(); const elapsedMs = run.finishedAt ? new Date(run.finishedAt).getTime() - createdMs : now !== null ? Math.max(0, now - createdMs) : null; const eta = elapsedMs !== null && ratio > 0.02 && run.status === "running" ? (elapsedMs / ratio) * (1 - ratio) : null; const sim = run.result?.simulation; const cert = run.result?.certification ?? null; return ( {run.gameSlug} v{run.gameVersion} {run.status} {cert ? : null} } description={`Run ${run.id.slice(0, 8)} · started ${dateTime(run.createdAt)}${run.finishedAt ? ` · finished ${dateTime(run.finishedAt)}` : ""}`} tone={run.status === "failed" ? "danger" : undefined} > {run.status === "running" ? (
{int(progress)} / {int(total)} spins {(ratio * 100).toFixed(1)}%{elapsedMs !== null ? ` · ${durationMs(elapsedMs)} elapsed` : ""}{eta !== null ? ` · ~${durationMs(eta)} left` : ""}

Polling every 1.5 s. Worker threads keep the API responsive; you can leave this page and re-open the run later.

) : run.status === "failed" ? ( {run.error ?? "The simulation failed without an error message."} ) : sim ? (
spins {int(sim.spins)} bet {int(sim.bet)} SC observed {pct(sim.observedRtp, 3)} ({signedPct(sim.deviation, 3)}) duration {durationMs(sim.durationMs)}
) : null}
); }