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%
3.9 KB · 77 lines typescript
Raw Blame History
1import { Worker } from "node:worker_threads";2import os from "node:os";3import { mergeResults, simulate, type SimulationResult } from "@spinza/game-core";4import { RAW_GAMES } from "@spinza/games";56export interface ParallelOptions {7  spins: number;8  bet?: number;9  payScale?: number;10  threads?: number;11  seed?: number;12  onProgress?: (done: number, total: number) => void;13}1415/** Run a simulation across worker threads. Falls back to in-process for small runs. */16export async function simulateParallel(slug: string, opts: ParallelOptions): Promise<SimulationResult> {17  const base = RAW_GAMES.find((g) => g.slug === slug);18  if (!base) throw new Error(`unknown game ${slug}`);19  const payScale = opts.payScale ?? base.payScale;20  const bet = opts.bet ?? 100;21  const threads = Math.max(1, Math.min(opts.threads ?? Math.max(1, os.cpus().length - 2), 64));22  if (opts.spins < 200_000 || threads === 1) {23    return simulate({ ...base, payScale }, { spins: opts.spins, bet, seed: opts.seed, onProgress: (d) => opts.onProgress?.(d, opts.spins) });24  }25  const per = Math.ceil(opts.spins / threads);26  const progress = new Array(threads).fill(0);27  const workerUrl = new URL("./worker.ts", import.meta.url);28  const tasks = Array.from({ length: threads }, (_, i) => {29    const spins = i === threads - 1 ? opts.spins - per * (threads - 1) : per;30    return new Promise<SimulationResult>((resolve, reject) => {31      const w = new Worker(workerUrl, {32        workerData: { slug, spins, bet, payScale, seed: opts.seed !== undefined ? opts.seed + i * 7919 : undefined },33        execArgv: process.execArgv.some((a) => a.includes("tsx")) ? process.execArgv : ["--import", "tsx"],34      });35      w.on("message", (m: { type: string; done?: number; result?: SimulationResult }) => {36        if (m.type === "progress" && m.done !== undefined) {37          progress[i] = m.done;38          opts.onProgress?.(progress.reduce((a, b) => a + b, 0), opts.spins);39        } else if (m.type === "done" && m.result) {40          progress[i] = spins;41          resolve(m.result);42        }43      });44      w.on("error", reject);45      w.on("exit", (code) => {46        if (code !== 0) reject(new Error(`worker exited with code ${code}`));47      });48    });49  });50  const parts = await Promise.all(tasks);51  return mergeResults(parts);52}5354/** Iteratively find the payScale that brings observed RTP to the configured target. */55export async function calibrate(slug: string, opts: { spins?: number; iterations?: number; threads?: number; log?: (s: string) => void } = {}): Promise<{ payScale: number; result: SimulationResult }> {56  const base = RAW_GAMES.find((g) => g.slug === slug);57  if (!base) throw new Error(`unknown game ${slug}`);58  const spins = opts.spins ?? 1_000_000;59  const iterations = opts.iterations ?? 5;60  let payScale = 1;61  // Iteration 0 is a coarse probe (fewer spins) since the initial RTP can be off by 10×.62  let result = await simulateParallel(slug, { spins: Math.max(200_000, Math.floor(spins / 4)), payScale, threads: opts.threads });63  const fmt = (r: SimulationResult) => `rtp=${(r.observedRtp * 100).toFixed(2)}% ±${((r.stdDev / Math.sqrt(r.spins)) * 100).toFixed(2)}% hit=${(r.hitRate * 100).toFixed(1)}%`;64  opts.log?.(`  iter 0: payScale=${payScale.toFixed(4)} ${fmt(result)}`);65  for (let i = 1; i <= iterations; i++) {66    const se = result.stdDev / Math.sqrt(result.spins);67    // Always run at least one full-size iteration; afterwards stop once within half a standard error.68    if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break;69    // Wins scale ~linearly with payScale except for jackpots/rounding/caps; damp the correction.70    const ratio = base.rtp / result.observedRtp;71    payScale = Math.min(5, Math.max(0.05, payScale * (1 + (ratio - 1) * 0.9)));72    result = await simulateParallel(slug, { spins, payScale, threads: opts.threads });73    opts.log?.(`  iter ${i}: payScale=${payScale.toFixed(4)} ${fmt(result)}`);74  }75  return { payScale: Number(payScale.toFixed(4)), result };76}77