TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import type { CertificationReport, SimulationResult } from "@spinza/game-core";5import { Bars, ChartFrame, CHART, Histogram, Lines } from "./charts";6import { KV, Panel, PassFail, SegmentedControl, StatGrid, StatTile } from "./primitives";7import { durationMs, int, multiplier, pct, sc, signedPct } from "./format";8import { cn } from "@/lib/utils";910/** KPI tiles + charts for a finished simulation. Shared by the simulator and the game detail page. */11export function SimulationResults({ sim, cert, compactTiles }: { sim: SimulationResult; cert: CertificationReport | null; compactTiles?: boolean }) {12 const [scale, setScale] = React.useState<"log" | "linear">("log");13 const devTone = Math.abs(sim.deviation) <= 0.004 ? "success" : Math.abs(sim.deviation) <= 0.015 ? "neutral" : "danger";14 const features = Object.entries(sim.featureCounts)15 .map(([feature, count]) => ({ feature, count, rate: count / sim.spins }))16 .sort((a, b) => b.count - a.count);17 const variance = sim.stdDev * sim.stdDev;18 const convDomain: [number, number] = [Math.max(0, Math.min(sim.configuredRtp, ...sim.convergence.map((c) => c.rtp)) - 0.02), Math.max(sim.configuredRtp, ...sim.convergence.map((c) => c.rtp)) + 0.02];1920 return (21 <div className="space-y-4">22 <StatGrid cols={6}>23 <StatTile label="Configured RTP" value={pct(sim.configuredRtp)} compact={compactTiles} />24 <StatTile label="Observed RTP" value={pct(sim.observedRtp, 3)} tone="accent" compact={compactTiles} />25 <StatTile label="Deviation" value={signedPct(sim.deviation, 3)} tone={devTone} sub="observed − configured" compact={compactTiles} />26 <StatTile label="Std dev (×bet)" value={sim.stdDev.toFixed(2)} sub={`variance ${variance.toFixed(2)}`} compact={compactTiles} />27 <StatTile label="Hit rate" value={pct(sim.hitRate)} compact={compactTiles} />28 <StatTile label="Max win" value={multiplier(sim.maxWinMultiplier)} sub={sc(sim.maxWin)} compact={compactTiles} />29 </StatGrid>30 <StatGrid cols={6}>31 <StatTile label="Bonus rate" value={pct(sim.bonusRate, 3)} sub={`1 in ${sim.bonusRate ? Math.round(1 / sim.bonusRate).toLocaleString("en-US") : "∞"}`} compact={compactTiles} />32 <StatTile label="Free-spin rate" value={pct(sim.freeSpinRate, 3)} sub={`1 in ${sim.freeSpinRate ? Math.round(1 / sim.freeSpinRate).toLocaleString("en-US") : "∞"}`} compact={compactTiles} />33 <StatTile label="Jackpot rate" value={pct(sim.jackpotRate, 4)} sub={`1 in ${sim.jackpotRate ? Math.round(1 / sim.jackpotRate).toLocaleString("en-US") : "∞"}`} compact={compactTiles} />34 <StatTile label="Median / avg win" value={<span>{sc(sim.medianWin)} <span className="text-fg-4">/</span> {sc(Math.round(sim.averageWin))}</span>} sub={`bet ${sc(sim.bet)}`} compact={compactTiles} />35 <StatTile label="Capped rounds" value={int(sim.cappedRounds)} tone={sim.cappedRounds / sim.spins > 0.0005 ? "danger" : "neutral"} sub={pct(sim.cappedRounds / sim.spins, 4)} compact={compactTiles} />36 <StatTile label="Duration" value={durationMs(sim.durationMs)} sub={`${int(sim.spins)} spins · ${Math.round(sim.spins / Math.max(1, sim.durationMs / 1000)).toLocaleString("en-US")} spins/s`} compact={compactTiles} />37 </StatGrid>3839 <div className="grid gap-4 xl:grid-cols-2">40 <Panel>41 <ChartFrame title="Win distribution" subtitle="Rounds per win multiplier bucket" height={240} right={<SegmentedControl size="xs" value={scale} onChange={setScale} items={[{ value: "log", label: "Log" }, { value: "linear", label: "Linear" }]} />}>42 <Histogram data={sim.distribution.map((b) => ({ ...b }))} x="label" y="count" logScale={scale === "log"} yFormat={(v) => (v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}M` : v >= 1000 ? `${(v / 1000).toFixed(0)}K` : String(Math.round(v)))} />43 </ChartFrame>44 <DistributionTable rows={sim.distribution} />45 </Panel>46 <Panel>47 <ChartFrame title="RTP convergence" subtitle={`Running RTP vs configured ${pct(sim.configuredRtp)}`} height={240}>48 <Lines data={sim.convergence.map((c) => ({ ...c }))} x="spins" series={[{ key: "rtp", label: "Observed RTP", color: CHART.accent }]} xFormat={(v) => shortNum(Number(v))} yFormat={(v) => `${(v * 100).toFixed(1)}%`} yDomain={convDomain} reference={{ y: sim.configuredRtp, label: `target ${pct(sim.configuredRtp)}` }} tooltipLabel={(l) => `${int(l)} spins`} />49 </ChartFrame>50 <ChartFrame title="Feature frequency" subtitle="Rounds in which each feature fired" height={Math.max(120, 28 * features.length + 30)} className="mt-6">51 {features.length ? <Bars data={features} x="feature" layout="vertical" series={[{ key: "count", label: "Rounds", color: CHART.accent }]} yFormat={(v) => shortNum(v)} tooltipLabel={(l, row) => `${String(l)} · ${pct(Number(row?.rate ?? 0), 3)} of rounds`} /> : <div className="grid h-full place-items-center text-[12px] text-fg-4">No feature fired during this run.</div>}52 </ChartFrame>53 </Panel>54 </div>5556 {cert ? <CertificationBlock cert={cert} /> : null}57 </div>58 );59}6061function DistributionTable({ rows }: { rows: SimulationResult["distribution"] }) {62 return (63 <details className="mt-3 text-[12px]">64 <summary className="cursor-pointer text-fg-3 hover:text-fg">Table view</summary>65 <table className="mt-2 w-full text-[12px] tabular">66 <thead>67 <tr className="text-left text-[10px] uppercase tracking-wider text-fg-4">68 <th className="py-1">Bucket</th>69 <th className="py-1 text-right">Rounds</th>70 <th className="py-1 text-right">Share</th>71 </tr>72 </thead>73 <tbody>74 {rows.map((b) => (75 <tr key={b.label} className="border-t border-line/60">76 <td className="py-1">{b.label}</td>77 <td className="py-1 text-right">{int(b.count)}</td>78 <td className="py-1 text-right text-fg-3">{pct(b.share, 3)}</td>79 </tr>80 ))}81 </tbody>82 </table>83 </details>84 );85}8687export function CertificationBlock({ cert, title = "Certification report" }: { cert: CertificationReport; title?: string }) {88 return (89 <Panel title={title} description={`${cert.name} · ${cert.game}@${cert.version} · certified ${new Date(cert.certifiedAt).toLocaleString("en-US")}`} actions={<PassFail pass={cert.status === "PASS"} label={cert.status} />} tone={cert.status === "PASS" ? "success" : "danger"}>90 <KV91 cols={3}92 items={[93 { label: "Spins", value: int(cert.spins) },94 { label: "Configured RTP", value: pct(cert.configuredRtp) },95 { label: "Observed RTP", value: pct(cert.observedRtp, 3) },96 { label: "Deviation", value: signedPct(cert.deviation, 3) },97 { label: "Hit rate", value: pct(cert.hitRate) },98 { label: "Bonus / free spins", value: `${pct(cert.bonusRate, 3)} / ${pct(cert.freeSpinRate, 3)}` },99 { label: "Max win", value: multiplier(cert.maxWinMultiplier) },100 { label: "Std dev", value: cert.stdDev.toFixed(2) },101 { label: "Rules", value: `±${(cert.rules.maxDeviation * 100).toFixed(2)}% · min ${int(cert.rules.minSpins)} · band ${pct(cert.rules.rtpBand[0], 0)}–${pct(cert.rules.rtpBand[1], 0)}` },102 ]}103 />104 <ul className="mt-4 divide-y divide-line/60 rounded-sm border border-line">105 {cert.checks.map((c) => (106 <li key={c.name} className="flex items-start gap-3 px-3 py-2 text-[13px]">107 <PassFail pass={c.pass} />108 <div className="min-w-0">109 <div className="font-mono text-[12px] text-fg">{c.name}</div>110 <div className={cn("text-[12px]", c.pass ? "text-fg-3" : "text-danger")}>{c.detail}</div>111 </div>112 </li>113 ))}114 </ul>115 </Panel>116 );117}118119function shortNum(v: number): string {120 if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(v % 1_000_000 === 0 ? 0 : 1)}M`;121 if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}K`;122 return String(Math.round(v));123}124