import type { GameDefinition, PlayerGameState } from "../types"; import { emptyPlayerState } from "../types"; import { runSpin } from "../engine"; import { CryptoRng, SeededRng, type Rng } from "../rng"; export interface SimulationOptions { spins: number; bet?: number; seed?: number; /** Progress callback every `progressEvery` spins. */ onProgress?: (done: number) => void; progressEvery?: number; /** Persist player state across spins (meters/heat) — default true, mirrors real play. */ persistState?: boolean; } export interface SimulationResult { game: string; version: string; spins: number; bet: number; configuredRtp: number; observedRtp: number; deviation: number; hitRate: number; bonusRate: number; freeSpinRate: number; jackpotRate: number; wagered: number; returned: number; averageWin: number; medianWin: number; maxWin: number; maxWinMultiplier: number; stdDev: number; /** Win multiplier buckets: [label, count]. */ distribution: { label: string; min: number; max: number; count: number; share: number }[]; /** RTP convergence samples. */ convergence: { spins: number; rtp: number }[]; featureCounts: Record; cappedRounds: number; durationMs: number; } const BUCKETS: { label: string; min: number; max: number }[] = [ { 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–1000×", min: 500, max: 1000 }, { label: "1000×+", min: 1000, max: Infinity }, ]; export function simulate(def: GameDefinition, opts: SimulationOptions): SimulationResult { const spins = Math.max(1, Math.floor(opts.spins)); const bet = opts.bet ?? 100; const rng: Rng = opts.seed !== undefined ? new SeededRng(opts.seed) : new CryptoRng(); const persist = opts.persistState !== false; let state: PlayerGameState = emptyPlayerState(); const started = Date.now(); let returned = 0; let hits = 0; let bonuses = 0; let freeSpins = 0; let jackpots = 0; let capped = 0; let maxWin = 0; let sum = 0; let sumSq = 0; const bucketCounts = new Array(BUCKETS.length).fill(0); const featureCounts: Record = {}; const convergence: { spins: number; rtp: number }[] = []; const convergencePoints = 40; const convEvery = Math.max(1, Math.floor(spins / convergencePoints)); // Reservoir sample for median (exact median of 10M wins is expensive; sample 200k). const sampleSize = Math.min(spins, 200_000); const sample = new Float64Array(sampleSize); let seen = 0; const wagered = spins * bet; const progressEvery = opts.progressEvery ?? 50_000; for (let i = 0; i < spins; i++) { const out = runSpin(def, bet, { rng, state: persist ? state : undefined }); if (persist) state = out.stateAfter; const w = out.totalWin; returned += w; if (w > 0) hits++; if (out.bonusTriggered) bonuses++; if (out.freeSpinsTriggered) freeSpins++; if (out.jackpot) jackpots++; if (out.capped) capped++; if (w > maxWin) maxWin = w; const m = w / bet; sum += m; sumSq += m * m; for (let b = 0; b < BUCKETS.length; b++) { const bk = BUCKETS[b]; if (m >= bk.min && (m < bk.max || (bk.max === 0 && m === 0))) { bucketCounts[b]++; break; } } for (const f of out.features) featureCounts[f] = (featureCounts[f] ?? 0) + 1; // Reservoir sampling. if (seen < sampleSize) sample[seen] = m; else { const j = rng.int(seen + 1); if (j < sampleSize) sample[j] = m; } seen++; if ((i + 1) % convEvery === 0 || i === spins - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) }); if (opts.onProgress && (i + 1) % progressEvery === 0) opts.onProgress(i + 1); } const n = spins; const mean = sum / n; const variance = Math.max(0, sumSq / n - mean * mean); const sorted = Array.from(sample.subarray(0, Math.min(seen, sampleSize))).sort((a, b) => a - b); const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0; const observedRtp = returned / wagered; return { game: def.slug, version: def.version, spins, bet, configuredRtp: def.rtp, observedRtp, deviation: observedRtp - def.rtp, hitRate: hits / n, bonusRate: bonuses / n, freeSpinRate: freeSpins / n, jackpotRate: jackpots / n, wagered, returned, averageWin: returned / n, medianWin: median * bet, maxWin, maxWinMultiplier: maxWin / bet, stdDev: Math.sqrt(variance), distribution: BUCKETS.map((b, i) => ({ ...b, count: bucketCounts[i], share: bucketCounts[i] / n })), convergence, featureCounts, cappedRounds: capped, durationMs: Date.now() - started, }; } /** Merge several partial simulation results (from worker threads) into one. */ export function mergeResults(parts: SimulationResult[]): SimulationResult { if (parts.length === 1) return parts[0]; const first = parts[0]; const spins = parts.reduce((a, p) => a + p.spins, 0); const wagered = parts.reduce((a, p) => a + p.wagered, 0); const returned = parts.reduce((a, p) => a + p.returned, 0); const hits = parts.reduce((a, p) => a + p.hitRate * p.spins, 0); const bonuses = parts.reduce((a, p) => a + p.bonusRate * p.spins, 0); const fs = parts.reduce((a, p) => a + p.freeSpinRate * p.spins, 0); const jp = parts.reduce((a, p) => a + p.jackpotRate * p.spins, 0); const maxWin = Math.max(...parts.map((p) => p.maxWin)); // Pooled variance (same means approx): E[x²] = var + mean² const meanAll = returned / wagered; const ex2 = parts.reduce((a, p) => a + (p.stdDev * p.stdDev + Math.pow(p.observedRtp, 2)) * p.spins, 0) / spins; const variance = Math.max(0, ex2 - meanAll * meanAll); const distribution = first.distribution.map((b, i) => { const count = parts.reduce((a, p) => a + p.distribution[i].count, 0); return { ...b, count, share: count / spins }; }); const featureCounts: Record = {}; for (const p of parts) for (const [k, v] of Object.entries(p.featureCounts)) featureCounts[k] = (featureCounts[k] ?? 0) + v; // Convergence: use the longest part's curve scaled to the total (approximation for charts). const conv = parts .slice() .sort((a, b) => b.convergence.length - a.convergence.length)[0] .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 })); const medians = parts.map((p) => p.medianWin).sort((a, b) => a - b); return { ...first, spins, wagered, returned, observedRtp: meanAll, deviation: meanAll - first.configuredRtp, hitRate: hits / spins, bonusRate: bonuses / spins, freeSpinRate: fs / spins, jackpotRate: jp / spins, averageWin: returned / spins, medianWin: medians[Math.floor(medians.length / 2)], maxWin, maxWinMultiplier: maxWin / first.bet, stdDev: Math.sqrt(variance), distribution, convergence: conv, featureCounts, cappedRounds: parts.reduce((a, p) => a + p.cappedRounds, 0), durationMs: Math.max(...parts.map((p) => p.durationMs)), }; }