TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { GameDefinition, PlayerGameState } from "../types";2import { emptyPlayerState } from "../types";3import { runSpin } from "../engine";4import { CryptoRng, SeededRng, type Rng } from "../rng";56export interface SimulationOptions {7 spins: number;8 bet?: number;9 seed?: number;10 /** Progress callback every `progressEvery` spins. */11 onProgress?: (done: number) => void;12 progressEvery?: number;13 /** Persist player state across spins (meters/heat) — default true, mirrors real play. */14 persistState?: boolean;15}1617export interface SimulationResult {18 game: string;19 version: string;20 spins: number;21 bet: number;22 configuredRtp: number;23 observedRtp: number;24 deviation: number;25 hitRate: number;26 bonusRate: number;27 freeSpinRate: number;28 jackpotRate: number;29 wagered: number;30 returned: number;31 averageWin: number;32 medianWin: number;33 maxWin: number;34 maxWinMultiplier: number;35 stdDev: number;36 /** Win multiplier buckets: [label, count]. */37 distribution: { label: string; min: number; max: number; count: number; share: number }[];38 /** RTP convergence samples. */39 convergence: { spins: number; rtp: number }[];40 featureCounts: Record<string, number>;41 cappedRounds: number;42 durationMs: number;43}4445const BUCKETS: { label: string; min: number; max: number }[] = [46 { label: "0×", min: 0, max: 0 },47 { label: "0–1×", min: 0.000001, max: 1 },48 { label: "1–2×", min: 1, max: 2 },49 { label: "2–5×", min: 2, max: 5 },50 { label: "5–10×", min: 5, max: 10 },51 { label: "10–20×", min: 10, max: 20 },52 { label: "20–50×", min: 20, max: 50 },53 { label: "50–100×", min: 50, max: 100 },54 { label: "100–500×", min: 100, max: 500 },55 { label: "500–1000×", min: 500, max: 1000 },56 { label: "1000×+", min: 1000, max: Infinity },57];5859export function simulate(def: GameDefinition, opts: SimulationOptions): SimulationResult {60 const spins = Math.max(1, Math.floor(opts.spins));61 const bet = opts.bet ?? 100;62 const rng: Rng = opts.seed !== undefined ? new SeededRng(opts.seed) : new CryptoRng();63 const persist = opts.persistState !== false;64 let state: PlayerGameState = emptyPlayerState();65 const started = Date.now();6667 let returned = 0;68 let hits = 0;69 let bonuses = 0;70 let freeSpins = 0;71 let jackpots = 0;72 let capped = 0;73 let maxWin = 0;74 let sum = 0;75 let sumSq = 0;76 const bucketCounts = new Array(BUCKETS.length).fill(0);77 const featureCounts: Record<string, number> = {};78 const convergence: { spins: number; rtp: number }[] = [];79 const convergencePoints = 40;80 const convEvery = Math.max(1, Math.floor(spins / convergencePoints));81 // Reservoir sample for median (exact median of 10M wins is expensive; sample 200k).82 const sampleSize = Math.min(spins, 200_000);83 const sample = new Float64Array(sampleSize);84 let seen = 0;85 const wagered = spins * bet;86 const progressEvery = opts.progressEvery ?? 50_000;8788 for (let i = 0; i < spins; i++) {89 const out = runSpin(def, bet, { rng, state: persist ? state : undefined });90 if (persist) state = out.stateAfter;91 const w = out.totalWin;92 returned += w;93 if (w > 0) hits++;94 if (out.bonusTriggered) bonuses++;95 if (out.freeSpinsTriggered) freeSpins++;96 if (out.jackpot) jackpots++;97 if (out.capped) capped++;98 if (w > maxWin) maxWin = w;99 const m = w / bet;100 sum += m;101 sumSq += m * m;102 for (let b = 0; b < BUCKETS.length; b++) {103 const bk = BUCKETS[b];104 if (m >= bk.min && (m < bk.max || (bk.max === 0 && m === 0))) {105 bucketCounts[b]++;106 break;107 }108 }109 for (const f of out.features) featureCounts[f] = (featureCounts[f] ?? 0) + 1;110 // Reservoir sampling.111 if (seen < sampleSize) sample[seen] = m;112 else {113 const j = rng.int(seen + 1);114 if (j < sampleSize) sample[j] = m;115 }116 seen++;117 if ((i + 1) % convEvery === 0 || i === spins - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) });118 if (opts.onProgress && (i + 1) % progressEvery === 0) opts.onProgress(i + 1);119 }120121 const n = spins;122 const mean = sum / n;123 const variance = Math.max(0, sumSq / n - mean * mean);124 const sorted = Array.from(sample.subarray(0, Math.min(seen, sampleSize))).sort((a, b) => a - b);125 const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;126 const observedRtp = returned / wagered;127128 return {129 game: def.slug,130 version: def.version,131 spins,132 bet,133 configuredRtp: def.rtp,134 observedRtp,135 deviation: observedRtp - def.rtp,136 hitRate: hits / n,137 bonusRate: bonuses / n,138 freeSpinRate: freeSpins / n,139 jackpotRate: jackpots / n,140 wagered,141 returned,142 averageWin: returned / n,143 medianWin: median * bet,144 maxWin,145 maxWinMultiplier: maxWin / bet,146 stdDev: Math.sqrt(variance),147 distribution: BUCKETS.map((b, i) => ({ ...b, count: bucketCounts[i], share: bucketCounts[i] / n })),148 convergence,149 featureCounts,150 cappedRounds: capped,151 durationMs: Date.now() - started,152 };153}154155/** Merge several partial simulation results (from worker threads) into one. */156export function mergeResults(parts: SimulationResult[]): SimulationResult {157 if (parts.length === 1) return parts[0];158 const first = parts[0];159 const spins = parts.reduce((a, p) => a + p.spins, 0);160 const wagered = parts.reduce((a, p) => a + p.wagered, 0);161 const returned = parts.reduce((a, p) => a + p.returned, 0);162 const hits = parts.reduce((a, p) => a + p.hitRate * p.spins, 0);163 const bonuses = parts.reduce((a, p) => a + p.bonusRate * p.spins, 0);164 const fs = parts.reduce((a, p) => a + p.freeSpinRate * p.spins, 0);165 const jp = parts.reduce((a, p) => a + p.jackpotRate * p.spins, 0);166 const maxWin = Math.max(...parts.map((p) => p.maxWin));167 // Pooled variance (same means approx): E[x²] = var + mean²168 const meanAll = returned / wagered;169 const ex2 = parts.reduce((a, p) => a + (p.stdDev * p.stdDev + Math.pow(p.observedRtp, 2)) * p.spins, 0) / spins;170 const variance = Math.max(0, ex2 - meanAll * meanAll);171 const distribution = first.distribution.map((b, i) => {172 const count = parts.reduce((a, p) => a + p.distribution[i].count, 0);173 return { ...b, count, share: count / spins };174 });175 const featureCounts: Record<string, number> = {};176 for (const p of parts) for (const [k, v] of Object.entries(p.featureCounts)) featureCounts[k] = (featureCounts[k] ?? 0) + v;177 // Convergence: use the longest part's curve scaled to the total (approximation for charts).178 const conv = parts179 .slice()180 .sort((a, b) => b.convergence.length - a.convergence.length)[0]181 .convergence.map((c, i, arr) => ({ spins: Math.round((c.spins / arr[arr.length - 1].spins) * spins), rtp: parts.reduce((a, p) => a + (p.convergence[i]?.rtp ?? p.observedRtp), 0) / parts.length }));182 const medians = parts.map((p) => p.medianWin).sort((a, b) => a - b);183 return {184 ...first,185 spins,186 wagered,187 returned,188 observedRtp: meanAll,189 deviation: meanAll - first.configuredRtp,190 hitRate: hits / spins,191 bonusRate: bonuses / spins,192 freeSpinRate: fs / spins,193 jackpotRate: jp / spins,194 averageWin: returned / spins,195 medianWin: medians[Math.floor(medians.length / 2)],196 maxWin,197 maxWinMultiplier: maxWin / first.bet,198 stdDev: Math.sqrt(variance),199 distribution,200 convergence: conv,201 featureCounts,202 cappedRounds: parts.reduce((a, p) => a + p.cappedRounds, 0),203 durationMs: Math.max(...parts.map((p) => p.durationMs)),204 };205}206