TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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