SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
15.4 KB · 334 lines tsx
Raw Blame History
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