import type { Rng } from "../rng"; import { CryptoRng } from "../rng"; import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation"; import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; import { resolveDropzone, randomDropzoneInput } from "./dropzone"; import { resolveGridbreak, randomGridbreakInput } from "./gridbreak"; import { resolveOrbit, randomOrbitInput } from "./orbit"; import { simulateLadderRound } from "./ladder"; import type { CrashSimulationResult } from "../crash"; export * from "./types"; export * from "./ladder"; export * from "./dropzone"; export * from "./gridbreak"; export * from "./orbit"; /* ------------------------------------------------------------ dispatch */ export function resolveInstant(def: ArcadeGameDefinition, bet: number, input: Record, rng: Rng = new CryptoRng()): ArcadeOutcome { switch (def.presentation.scene) { case "dropzone": return resolveDropzone(def, bet, rng, input as never); case "gridbreak": return resolveGridbreak(def, bet, rng, input as never); case "orbit": return resolveOrbit(def, bet, rng, input as never); default: throw new Error(`${def.slug} is not an instant game`); } } export function randomInput(def: ArcadeGameDefinition, rng: Rng): Record { switch (def.presentation.scene) { case "dropzone": return randomDropzoneInput(def, rng) as unknown as Record; case "gridbreak": return randomGridbreakInput(def, rng); case "orbit": return randomOrbitInput(def, rng); default: return {}; } } /* ---------------------------------------------------------- simulation */ export type ArcadeSimulationResult = CrashSimulationResult; export function simulateArcade(def: ArcadeGameDefinition, opts: { spins: number; bet?: number; rng?: Rng; payScale?: number }): ArcadeSimulationResult { const rng = opts.rng ?? new CryptoRng(); const bet = opts.bet ?? 100; const d = opts.payScale !== undefined ? { ...def, payScale: opts.payScale } : def; const n = opts.spins; const started = Date.now(); let returned = 0; let hits = 0; let sum = 0; let sumSq = 0; let maxWin = 0; let capped = 0; const buckets = [ { label: "0×", min: 0, max: 0 }, { label: "0–1×", min: 0.000001, max: 1 }, { label: "1–2×", min: 1, max: 2 }, { label: "2–5×", min: 2, max: 5 }, { label: "5–10×", min: 5, max: 10 }, { label: "10–20×", min: 10, max: 20 }, { label: "20–50×", min: 20, max: 50 }, { label: "50–100×", min: 50, max: 100 }, { label: "100–500×", min: 100, max: 500 }, { label: "500×+", min: 500, max: Infinity }, ]; const counts = new Array(buckets.length).fill(0); const feature: Record = {}; const convergence: { spins: number; rtp: number }[] = []; const every = Math.max(1, Math.floor(n / 40)); const sample: number[] = []; for (let i = 0; i < n; i++) { let win: number; if (d.mode === "ladder") win = simulateLadderRound(d, bet, rng); else { const out = resolveInstant(d, bet, randomInput(d, rng), rng); win = out.totalWin; if (out.capped) capped++; for (const f of out.features) feature[f] = (feature[f] ?? 0) + 1; } returned += win; if (win > 0) hits++; const m = win / bet; sum += m; sumSq += m * m; if (win > maxWin) maxWin = win; for (let b = 0; b < buckets.length; b++) if ((buckets[b].max === 0 && m === 0) || (buckets[b].max !== 0 && m >= buckets[b].min && m < buckets[b].max)) { counts[b]++; break; } if (sample.length < 100_000) sample.push(m); if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) }); } const mean = sum / n; const variance = Math.max(0, sumSq / n - mean * mean); sample.sort((a, b) => a - b); const observedRtp = returned / (n * bet); return { game: d.slug, version: d.version, spins: n, bet, configuredRtp: d.rtp, observedRtp, deviation: observedRtp - d.rtp, hitRate: hits / n, bonusRate: 0, freeSpinRate: 0, jackpotRate: 0, wagered: n * bet, returned, averageWin: returned / n, medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet, maxWin, maxWinMultiplier: maxWin / bet, stdDev: Math.sqrt(variance), distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })), convergence, featureCounts: feature, cappedRounds: capped, durationMs: Date.now() - started, }; } /** Find the payScale bringing an instant game to its target RTP (ladders are analytic). */ export function calibrateArcade(def: ArcadeGameDefinition, spins = 400_000, iterations = 4, log?: (s: string) => void): { payScale: number; result: ArcadeSimulationResult } { let payScale = 1; let result = simulateArcade(def, { spins: Math.max(100_000, Math.floor(spins / 4)), payScale }); log?.(` iter 0: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`); for (let i = 1; i <= iterations; i++) { const se = result.stdDev / Math.sqrt(result.spins); if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break; payScale = Math.min(10, Math.max(0.01, payScale * (1 + (def.rtp / result.observedRtp - 1) * 0.9))); result = simulateArcade(def, { spins, payScale }); log?.(` iter ${i}: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`); } return { payScale: Number(payScale.toFixed(4)), result }; } export function certifyArcade(def: ArcadeGameDefinition, sim: ArcadeSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport { const se = sim.stdDev / Math.sqrt(sim.spins); const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se)); const checks = [ { name: "definition", pass: def.rtp >= 0.94 && def.rtp <= 0.98 && def.maxMultiplier >= 10 && def.payScale > 0, detail: `rtp ${def.rtp}, cap ${def.maxMultiplier}×, payScale ${def.payScale}` }, { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` }, { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` }, { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` }, { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` }, { name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` }, ]; 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: 0, freeSpinRate: 0, maxWinMultiplier: sim.maxWinMultiplier, stdDev: sim.stdDev, status: checks.every((c) => c.pass) ? "PASS" : "FAIL", checks, certifiedAt: new Date().toISOString(), rules, distribution: sim.distribution, convergence: sim.convergence, featureCounts: sim.featureCounts, durationMs: sim.durationMs, }; } type ArcadeInput = Omit & Partial>; export function defineArcadeGame(input: ArcadeInput): ArcadeGameDefinition { return { kind: "arcade", minBet: 10, maxBet: 1000, payScale: 1, tags: ["original", "beyond-slots"], featureNames: [], ...input }; }