TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { Rng } from "../rng";2import { CryptoRng } from "../rng";3import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation";4import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";5import { resolveDropzone, randomDropzoneInput } from "./dropzone";6import { resolveGridbreak, randomGridbreakInput } from "./gridbreak";7import { resolveOrbit, randomOrbitInput } from "./orbit";8import { simulateLadderRound } from "./ladder";9import type { CrashSimulationResult } from "../crash";1011export * from "./types";12export * from "./ladder";13export * from "./dropzone";14export * from "./gridbreak";15export * from "./orbit";1617/* ------------------------------------------------------------ dispatch */1819export function resolveInstant(def: ArcadeGameDefinition, bet: number, input: Record<string, unknown>, rng: Rng = new CryptoRng()): ArcadeOutcome {20 switch (def.presentation.scene) {21 case "dropzone":22 return resolveDropzone(def, bet, rng, input as never);23 case "gridbreak":24 return resolveGridbreak(def, bet, rng, input as never);25 case "orbit":26 return resolveOrbit(def, bet, rng, input as never);27 default:28 throw new Error(`${def.slug} is not an instant game`);29 }30}3132export function randomInput(def: ArcadeGameDefinition, rng: Rng): Record<string, unknown> {33 switch (def.presentation.scene) {34 case "dropzone":35 return randomDropzoneInput(def, rng) as unknown as Record<string, unknown>;36 case "gridbreak":37 return randomGridbreakInput(def, rng);38 case "orbit":39 return randomOrbitInput(def, rng);40 default:41 return {};42 }43}4445/* ---------------------------------------------------------- simulation */4647export type ArcadeSimulationResult = CrashSimulationResult;4849export function simulateArcade(def: ArcadeGameDefinition, opts: { spins: number; bet?: number; rng?: Rng; payScale?: number }): ArcadeSimulationResult {50 const rng = opts.rng ?? new CryptoRng();51 const bet = opts.bet ?? 100;52 const d = opts.payScale !== undefined ? { ...def, payScale: opts.payScale } : def;53 const n = opts.spins;54 const started = Date.now();55 let returned = 0;56 let hits = 0;57 let sum = 0;58 let sumSq = 0;59 let maxWin = 0;60 let capped = 0;61 const buckets = [62 { label: "0×", min: 0, max: 0 },63 { label: "0–1×", min: 0.000001, max: 1 },64 { label: "1–2×", min: 1, max: 2 },65 { label: "2–5×", min: 2, max: 5 },66 { label: "5–10×", min: 5, max: 10 },67 { label: "10–20×", min: 10, max: 20 },68 { label: "20–50×", min: 20, max: 50 },69 { label: "50–100×", min: 50, max: 100 },70 { label: "100–500×", min: 100, max: 500 },71 { label: "500×+", min: 500, max: Infinity },72 ];73 const counts = new Array(buckets.length).fill(0);74 const feature: Record<string, number> = {};75 const convergence: { spins: number; rtp: number }[] = [];76 const every = Math.max(1, Math.floor(n / 40));77 const sample: number[] = [];78 for (let i = 0; i < n; i++) {79 let win: number;80 if (d.mode === "ladder") win = simulateLadderRound(d, bet, rng);81 else {82 const out = resolveInstant(d, bet, randomInput(d, rng), rng);83 win = out.totalWin;84 if (out.capped) capped++;85 for (const f of out.features) feature[f] = (feature[f] ?? 0) + 1;86 }87 returned += win;88 if (win > 0) hits++;89 const m = win / bet;90 sum += m;91 sumSq += m * m;92 if (win > maxWin) maxWin = win;93 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)) {94 counts[b]++;95 break;96 }97 if (sample.length < 100_000) sample.push(m);98 if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) });99 }100 const mean = sum / n;101 const variance = Math.max(0, sumSq / n - mean * mean);102 sample.sort((a, b) => a - b);103 const observedRtp = returned / (n * bet);104 return {105 game: d.slug,106 version: d.version,107 spins: n,108 bet,109 configuredRtp: d.rtp,110 observedRtp,111 deviation: observedRtp - d.rtp,112 hitRate: hits / n,113 bonusRate: 0,114 freeSpinRate: 0,115 jackpotRate: 0,116 wagered: n * bet,117 returned,118 averageWin: returned / n,119 medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet,120 maxWin,121 maxWinMultiplier: maxWin / bet,122 stdDev: Math.sqrt(variance),123 distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })),124 convergence,125 featureCounts: feature,126 cappedRounds: capped,127 durationMs: Date.now() - started,128 };129}130131/** Find the payScale bringing an instant game to its target RTP (ladders are analytic). */132export function calibrateArcade(def: ArcadeGameDefinition, spins = 400_000, iterations = 4, log?: (s: string) => void): { payScale: number; result: ArcadeSimulationResult } {133 let payScale = 1;134 let result = simulateArcade(def, { spins: Math.max(100_000, Math.floor(spins / 4)), payScale });135 log?.(` iter 0: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`);136 for (let i = 1; i <= iterations; i++) {137 const se = result.stdDev / Math.sqrt(result.spins);138 if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break;139 payScale = Math.min(10, Math.max(0.01, payScale * (1 + (def.rtp / result.observedRtp - 1) * 0.9)));140 result = simulateArcade(def, { spins, payScale });141 log?.(` iter ${i}: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`);142 }143 return { payScale: Number(payScale.toFixed(4)), result };144}145146export function certifyArcade(def: ArcadeGameDefinition, sim: ArcadeSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport {147 const se = sim.stdDev / Math.sqrt(sim.spins);148 const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se));149 const checks = [150 { 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}` },151 { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` },152 { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` },153 { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` },154 { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` },155 { name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` },156 ];157 return {158 game: def.slug,159 name: def.name,160 version: def.version,161 spins: sim.spins,162 configuredRtp: def.rtp,163 observedRtp: sim.observedRtp,164 deviation: sim.deviation,165 hitRate: sim.hitRate,166 bonusRate: 0,167 freeSpinRate: 0,168 maxWinMultiplier: sim.maxWinMultiplier,169 stdDev: sim.stdDev,170 status: checks.every((c) => c.pass) ? "PASS" : "FAIL",171 checks,172 certifiedAt: new Date().toISOString(),173 rules,174 distribution: sim.distribution,175 convergence: sim.convergence,176 featureCounts: sim.featureCounts,177 durationMs: sim.durationMs,178 };179}180181type ArcadeInput = Omit<ArcadeGameDefinition, "kind" | "featureNames" | "tags" | "minBet" | "maxBet" | "payScale"> & Partial<Pick<ArcadeGameDefinition, "featureNames" | "tags" | "minBet" | "maxBet" | "payScale">>;182183export function defineArcadeGame(input: ArcadeInput): ArcadeGameDefinition {184 return { kind: "arcade", minBet: 10, maxBet: 1000, payScale: 1, tags: ["original", "beyond-slots"], featureNames: [], ...input };185}186