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%
7.4 KB · 156 lines typescript
Raw Blame History
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