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.3 KB · 206 lines typescript
Raw Blame History
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