import { Worker } from "node:worker_threads"; import os from "node:os"; import { mergeResults, simulate, type SimulationResult } from "@spinza/game-core"; import { RAW_GAMES } from "@spinza/games"; export interface ParallelOptions { spins: number; bet?: number; payScale?: number; threads?: number; seed?: number; onProgress?: (done: number, total: number) => void; } /** Run a simulation across worker threads. Falls back to in-process for small runs. */ export async function simulateParallel(slug: string, opts: ParallelOptions): Promise { const base = RAW_GAMES.find((g) => g.slug === slug); if (!base) throw new Error(`unknown game ${slug}`); const payScale = opts.payScale ?? base.payScale; const bet = opts.bet ?? 100; const threads = Math.max(1, Math.min(opts.threads ?? Math.max(1, os.cpus().length - 2), 64)); if (opts.spins < 200_000 || threads === 1) { return simulate({ ...base, payScale }, { spins: opts.spins, bet, seed: opts.seed, onProgress: (d) => opts.onProgress?.(d, opts.spins) }); } const per = Math.ceil(opts.spins / threads); const progress = new Array(threads).fill(0); const workerUrl = new URL("./worker.ts", import.meta.url); const tasks = Array.from({ length: threads }, (_, i) => { const spins = i === threads - 1 ? opts.spins - per * (threads - 1) : per; return new Promise((resolve, reject) => { const w = new Worker(workerUrl, { workerData: { slug, spins, bet, payScale, seed: opts.seed !== undefined ? opts.seed + i * 7919 : undefined }, execArgv: process.execArgv.some((a) => a.includes("tsx")) ? process.execArgv : ["--import", "tsx"], }); w.on("message", (m: { type: string; done?: number; result?: SimulationResult }) => { if (m.type === "progress" && m.done !== undefined) { progress[i] = m.done; opts.onProgress?.(progress.reduce((a, b) => a + b, 0), opts.spins); } else if (m.type === "done" && m.result) { progress[i] = spins; resolve(m.result); } }); w.on("error", reject); w.on("exit", (code) => { if (code !== 0) reject(new Error(`worker exited with code ${code}`)); }); }); }); const parts = await Promise.all(tasks); return mergeResults(parts); } /** Iteratively find the payScale that brings observed RTP to the configured target. */ export async function calibrate(slug: string, opts: { spins?: number; iterations?: number; threads?: number; log?: (s: string) => void } = {}): Promise<{ payScale: number; result: SimulationResult }> { const base = RAW_GAMES.find((g) => g.slug === slug); if (!base) throw new Error(`unknown game ${slug}`); const spins = opts.spins ?? 1_000_000; const iterations = opts.iterations ?? 5; let payScale = 1; // Iteration 0 is a coarse probe (fewer spins) since the initial RTP can be off by 10×. let result = await simulateParallel(slug, { spins: Math.max(200_000, Math.floor(spins / 4)), payScale, threads: opts.threads }); 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)}%`; opts.log?.(` iter 0: payScale=${payScale.toFixed(4)} ${fmt(result)}`); for (let i = 1; i <= iterations; i++) { const se = result.stdDev / Math.sqrt(result.spins); // Always run at least one full-size iteration; afterwards stop once within half a standard error. if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break; // Wins scale ~linearly with payScale except for jackpots/rounding/caps; damp the correction. const ratio = base.rtp / result.observedRtp; payScale = Math.min(5, Math.max(0.05, payScale * (1 + (ratio - 1) * 0.9))); result = await simulateParallel(slug, { spins, payScale, threads: opts.threads }); opts.log?.(` iter ${i}: payScale=${payScale.toFixed(4)} ${fmt(result)}`); } return { payScale: Number(payScale.toFixed(4)), result }; }