TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { GameDefinition } from "../types";2import type { SimulationResult } from "../simulation";34export interface ValidationIssue {5 level: "error" | "warning";6 message: string;7}89/** Static validation of a definition (before any simulation). */10export function validateDefinition(def: GameDefinition): ValidationIssue[] {11 const issues: ValidationIssue[] = [];12 const err = (m: string) => issues.push({ level: "error", message: m });13 const warn = (m: string) => issues.push({ level: "warning", message: m });1415 if (!/^[a-z0-9-]+$/.test(def.slug)) err(`slug "${def.slug}" must be kebab-case`);16 if (!/^\d+\.\d+\.\d+$/.test(def.version)) err(`version "${def.version}" must be semver`);17 if (def.rtp < 0.94 || def.rtp > 0.98) err(`target RTP ${def.rtp} outside 94%–98%`);18 if (def.grid.reels < 3 || def.grid.reels > 8) err("reels must be 3..8");19 if (def.grid.rows < 2 || def.grid.rows > 8) err("rows must be 2..8");20 if (def.minBet < 1 || def.maxBet < def.minBet) err("invalid bet limits");21 if (def.maxMultiplier < 10) err("maxMultiplier must be >= 10");22 if (def.payScale <= 0 || def.payScale > 5) err("payScale must be in (0, 5]");23 const ids = new Set<string>();24 for (const s of def.symbols) {25 if (ids.has(s.id)) err(`duplicate symbol id ${s.id}`);26 ids.add(s.id);27 const w = Array.isArray(s.weight) ? s.weight : [s.weight];28 if (w.some((x) => x < 0)) err(`negative weight on ${s.id}`);29 if (s.kind === "regular" && !s.pays) err(`regular symbol ${s.id} has no pays`);30 if (s.pays) for (const [k, v] of Object.entries(s.pays)) if (Number(k) < 2 || v <= 0) err(`bad pay ${k}:${v} on ${s.id}`);31 }32 const regular = def.symbols.filter((s) => s.kind === "regular");33 if (regular.length < 5) err("need at least 5 regular symbols");34 if (def.wild && !ids.has(def.wild.id)) err("wild id not in symbols");35 if (def.scatter && !ids.has(def.scatter.id)) err("scatter id not in symbols");36 if (def.holdRespin && !ids.has(def.holdRespin.symbolId)) err("holdRespin symbol not in symbols");37 if (def.meter && !ids.has(def.meter.symbolId)) err("meter symbol not in symbols");38 if (def.mystery && !ids.has(def.mystery.symbolId)) err("mystery symbol not in symbols");39 if (def.spinCollect && !ids.has(def.spinCollect.symbolId)) err("spinCollect symbol not in symbols");40 if (def.payModel.type === "lines") {41 for (const l of def.payModel.lines) {42 if (l.length !== def.grid.reels) err("payline length must equal reels");43 if (l.some((row) => row < 0 || row >= def.grid.rows)) err("payline row out of range");44 }45 if (def.dynamicGrid) err("dynamic grid requires the ways pay model");46 }47 if (def.wild?.sticky && def.wild.moving) warn("sticky + moving wilds together is unusual");48 const totalBase = regular.reduce((a, s) => a + (Array.isArray(s.weight) ? s.weight[0] : s.weight), 0);49 if (totalBase <= 0) err("regular symbols have zero weight on reel 1");50 return issues;51}5253export interface CertificationRules {54 /** Maximum |observed − configured| RTP allowed. */55 maxDeviation: number;56 /** Minimum spins for a valid certification. */57 minSpins: number;58 /** Observed RTP must stay in this band regardless of target. */59 rtpBand: [number, number];60 /** Hit rate sanity band. */61 hitRateBand: [number, number];62 /** Max share of rounds hitting the win cap. */63 maxCappedShare: number;64}6566export const DEFAULT_CERTIFICATION_RULES: CertificationRules = {67 maxDeviation: 0.004,68 minSpins: 1_000_000,69 rtpBand: [0.93, 0.99],70 hitRateBand: [0.08, 0.6],71 maxCappedShare: 0.0005,72};7374export interface CertificationReport {75 game: string;76 name: string;77 version: string;78 spins: number;79 configuredRtp: number;80 observedRtp: number;81 deviation: number;82 hitRate: number;83 bonusRate: number;84 freeSpinRate: number;85 maxWinMultiplier: number;86 stdDev: number;87 status: "PASS" | "FAIL";88 checks: { name: string; pass: boolean; detail: string }[];89 certifiedAt: string;90 rules: CertificationRules;91 distribution: SimulationResult["distribution"];92 convergence: SimulationResult["convergence"];93 featureCounts: Record<string, number>;94 durationMs: number;95}9697export function certify(def: GameDefinition, sim: SimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport {98 const checks: CertificationReport["checks"] = [];99 const staticIssues = validateDefinition(def).filter((i) => i.level === "error");100 checks.push({ name: "definition", pass: staticIssues.length === 0, detail: staticIssues.map((i) => i.message).join("; ") || "ok" });101 checks.push({ name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} spins (min ${rules.minSpins.toLocaleString("en-US")})` });102 // Statistical tolerance: 3 standard errors of the mean multiplier, floored at the configured minimum103 // and capped at 1.5% absolute. High-volatility games need more spins to certify tightly.104 const standardError = sim.stdDev / Math.sqrt(sim.spins);105 const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * standardError));106 checks.push({107 name: "rtp-deviation",108 pass: Math.abs(sim.deviation) <= tolerance,109 detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}% = max(${(rules.maxDeviation * 100).toFixed(2)}%, 3σ/√n=${(3 * standardError * 100).toFixed(2)}%))`,110 });111 checks.push({ name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` });112 checks.push({ name: "hit-rate", pass: sim.hitRate >= rules.hitRateBand[0] && sim.hitRate <= rules.hitRateBand[1], detail: `${(sim.hitRate * 100).toFixed(2)}%` });113 checks.push({ name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` });114 checks.push({ name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` });115 const status = checks.every((c) => c.pass) ? "PASS" : "FAIL";116 return {117 game: def.slug,118 name: def.name,119 version: def.version,120 spins: sim.spins,121 configuredRtp: def.rtp,122 observedRtp: sim.observedRtp,123 deviation: sim.deviation,124 hitRate: sim.hitRate,125 bonusRate: sim.bonusRate,126 freeSpinRate: sim.freeSpinRate,127 maxWinMultiplier: sim.maxWinMultiplier,128 stdDev: sim.stdDev,129 status,130 checks,131 certifiedAt: new Date().toISOString(),132 rules,133 distribution: sim.distribution,134 convergence: sim.convergence,135 featureCounts: sim.featureCounts,136 durationMs: sim.durationMs,137 };138}139140export function formatCertification(r: CertificationReport): string {141 const lines = [142 "Spinza Internal Game Certification",143 "----------------------------------",144 `Game: ${r.name} (${r.game}@${r.version})`,145 `Simulation: ${r.spins.toLocaleString("en-US")} spins`,146 `Configured RTP: ${(r.configuredRtp * 100).toFixed(2)}%`,147 `Observed RTP: ${(r.observedRtp * 100).toFixed(2)}%`,148 `Deviation: ${r.deviation >= 0 ? "+" : ""}${(r.deviation * 100).toFixed(2)}%`,149 `Hit rate: ${(r.hitRate * 100).toFixed(2)}% Bonus rate: ${(r.bonusRate * 100).toFixed(3)}% Free spins: ${(r.freeSpinRate * 100).toFixed(3)}%`,150 `Max win: ${r.maxWinMultiplier.toFixed(1)}× Std dev: ${r.stdDev.toFixed(2)}`,151 `Status: ${r.status}`,152 ];153 for (const c of r.checks) lines.push(` [${c.pass ? "PASS" : "FAIL"}] ${c.name}: ${c.detail}`);154 return lines.join("\n");155}156