import type { GameDefinition } from "../types"; import type { SimulationResult } from "../simulation"; export interface ValidationIssue { level: "error" | "warning"; message: string; } /** Static validation of a definition (before any simulation). */ export function validateDefinition(def: GameDefinition): ValidationIssue[] { const issues: ValidationIssue[] = []; const err = (m: string) => issues.push({ level: "error", message: m }); const warn = (m: string) => issues.push({ level: "warning", message: m }); if (!/^[a-z0-9-]+$/.test(def.slug)) err(`slug "${def.slug}" must be kebab-case`); if (!/^\d+\.\d+\.\d+$/.test(def.version)) err(`version "${def.version}" must be semver`); if (def.rtp < 0.94 || def.rtp > 0.98) err(`target RTP ${def.rtp} outside 94%–98%`); if (def.grid.reels < 3 || def.grid.reels > 8) err("reels must be 3..8"); if (def.grid.rows < 2 || def.grid.rows > 8) err("rows must be 2..8"); if (def.minBet < 1 || def.maxBet < def.minBet) err("invalid bet limits"); if (def.maxMultiplier < 10) err("maxMultiplier must be >= 10"); if (def.payScale <= 0 || def.payScale > 5) err("payScale must be in (0, 5]"); const ids = new Set(); for (const s of def.symbols) { if (ids.has(s.id)) err(`duplicate symbol id ${s.id}`); ids.add(s.id); const w = Array.isArray(s.weight) ? s.weight : [s.weight]; if (w.some((x) => x < 0)) err(`negative weight on ${s.id}`); if (s.kind === "regular" && !s.pays) err(`regular symbol ${s.id} has no pays`); 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}`); } const regular = def.symbols.filter((s) => s.kind === "regular"); if (regular.length < 5) err("need at least 5 regular symbols"); if (def.wild && !ids.has(def.wild.id)) err("wild id not in symbols"); if (def.scatter && !ids.has(def.scatter.id)) err("scatter id not in symbols"); if (def.holdRespin && !ids.has(def.holdRespin.symbolId)) err("holdRespin symbol not in symbols"); if (def.meter && !ids.has(def.meter.symbolId)) err("meter symbol not in symbols"); if (def.mystery && !ids.has(def.mystery.symbolId)) err("mystery symbol not in symbols"); if (def.spinCollect && !ids.has(def.spinCollect.symbolId)) err("spinCollect symbol not in symbols"); if (def.payModel.type === "lines") { for (const l of def.payModel.lines) { if (l.length !== def.grid.reels) err("payline length must equal reels"); if (l.some((row) => row < 0 || row >= def.grid.rows)) err("payline row out of range"); } if (def.dynamicGrid) err("dynamic grid requires the ways pay model"); } if (def.wild?.sticky && def.wild.moving) warn("sticky + moving wilds together is unusual"); const totalBase = regular.reduce((a, s) => a + (Array.isArray(s.weight) ? s.weight[0] : s.weight), 0); if (totalBase <= 0) err("regular symbols have zero weight on reel 1"); return issues; } export interface CertificationRules { /** Maximum |observed − configured| RTP allowed. */ maxDeviation: number; /** Minimum spins for a valid certification. */ minSpins: number; /** Observed RTP must stay in this band regardless of target. */ rtpBand: [number, number]; /** Hit rate sanity band. */ hitRateBand: [number, number]; /** Max share of rounds hitting the win cap. */ maxCappedShare: number; } export const DEFAULT_CERTIFICATION_RULES: CertificationRules = { maxDeviation: 0.004, minSpins: 1_000_000, rtpBand: [0.93, 0.99], hitRateBand: [0.08, 0.6], maxCappedShare: 0.0005, }; export interface CertificationReport { game: string; name: string; version: string; spins: number; configuredRtp: number; observedRtp: number; deviation: number; hitRate: number; bonusRate: number; freeSpinRate: number; maxWinMultiplier: number; stdDev: number; status: "PASS" | "FAIL"; checks: { name: string; pass: boolean; detail: string }[]; certifiedAt: string; rules: CertificationRules; distribution: SimulationResult["distribution"]; convergence: SimulationResult["convergence"]; featureCounts: Record; durationMs: number; } export function certify(def: GameDefinition, sim: SimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport { const checks: CertificationReport["checks"] = []; const staticIssues = validateDefinition(def).filter((i) => i.level === "error"); checks.push({ name: "definition", pass: staticIssues.length === 0, detail: staticIssues.map((i) => i.message).join("; ") || "ok" }); checks.push({ name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} spins (min ${rules.minSpins.toLocaleString("en-US")})` }); // Statistical tolerance: 3 standard errors of the mean multiplier, floored at the configured minimum // and capped at 1.5% absolute. High-volatility games need more spins to certify tightly. const standardError = sim.stdDev / Math.sqrt(sim.spins); const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * standardError)); checks.push({ name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}% = max(${(rules.maxDeviation * 100).toFixed(2)}%, 3σ/√n=${(3 * standardError * 100).toFixed(2)}%))`, }); checks.push({ name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` }); checks.push({ name: "hit-rate", pass: sim.hitRate >= rules.hitRateBand[0] && sim.hitRate <= rules.hitRateBand[1], detail: `${(sim.hitRate * 100).toFixed(2)}%` }); checks.push({ name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` }); checks.push({ name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` }); const status = checks.every((c) => c.pass) ? "PASS" : "FAIL"; return { game: def.slug, name: def.name, version: def.version, spins: sim.spins, configuredRtp: def.rtp, observedRtp: sim.observedRtp, deviation: sim.deviation, hitRate: sim.hitRate, bonusRate: sim.bonusRate, freeSpinRate: sim.freeSpinRate, maxWinMultiplier: sim.maxWinMultiplier, stdDev: sim.stdDev, status, checks, certifiedAt: new Date().toISOString(), rules, distribution: sim.distribution, convergence: sim.convergence, featureCounts: sim.featureCounts, durationMs: sim.durationMs, }; } export function formatCertification(r: CertificationReport): string { const lines = [ "Spinza Internal Game Certification", "----------------------------------", `Game: ${r.name} (${r.game}@${r.version})`, `Simulation: ${r.spins.toLocaleString("en-US")} spins`, `Configured RTP: ${(r.configuredRtp * 100).toFixed(2)}%`, `Observed RTP: ${(r.observedRtp * 100).toFixed(2)}%`, `Deviation: ${r.deviation >= 0 ? "+" : ""}${(r.deviation * 100).toFixed(2)}%`, `Hit rate: ${(r.hitRate * 100).toFixed(2)}% Bonus rate: ${(r.bonusRate * 100).toFixed(3)}% Free spins: ${(r.freeSpinRate * 100).toFixed(3)}%`, `Max win: ${r.maxWinMultiplier.toFixed(1)}× Std dev: ${r.stdDev.toFixed(2)}`, `Status: ${r.status}`, ]; for (const c of r.checks) lines.push(` [${c.pass ? "PASS" : "FAIL"}] ${c.name}: ${c.detail}`); return lines.join("\n"); }