#!/usr/bin/env tsx /** * Spinza simulator CLI. * * pnpm sim run [--spins 1000000] [--bet 100] [--threads 8] [--seed 42] * pnpm sim calibrate [--spins 400000] [--iterations 4] * pnpm sim certify [--spins 1000000] * pnpm sim validate */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { certify, certifyCrash, certifyArcade, calibrateArcade, formatCertification, simulateCrash, simulateArcade, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games"; import { calibrate, simulateParallel } from "./index"; const here = path.dirname(fileURLToPath(import.meta.url)); const gamesDir = path.resolve(here, "../../../games"); const calibrationPath = path.join(gamesDir, "src/calibration.json"); const certDir = path.join(gamesDir, "certifications"); function arg(name: string, def?: string): string | undefined { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : def; } function num(name: string, def: number): number { const v = arg(name); return v ? Number(v.replace(/[_,]/g, "")) : def; } function targets(sel: string | undefined): string[] { if (!sel || sel === "all") return RAW_GAMES.map((g) => g.slug); return sel.split(","); } function readCalibration(): Record { try { return JSON.parse(fs.readFileSync(calibrationPath, "utf8")); } catch { return {}; } } function progressBar(done: number, total: number): void { const pct = Math.min(1, done / total); const w = 30; const bar = "█".repeat(Math.round(pct * w)).padEnd(w, "░"); process.stdout.write(`\r [${bar}] ${(pct * 100).toFixed(0)}% ${done.toLocaleString("en-US")}/${total.toLocaleString("en-US")}`); if (done >= total) process.stdout.write("\n"); } async function main() { const [cmd, sel] = process.argv.slice(2); const threads = num("threads", 0) || undefined; if (cmd === "validate") { let bad = 0; for (const g of RAW_GAMES) { const issues = validateDefinition(g); const errors = issues.filter((i) => i.level === "error"); console.log(`${errors.length ? "✗" : "✓"} ${g.slug}@${g.version} ${issues.map((i) => `[${i.level}] ${i.message}`).join(" | ")}`); if (errors.length) bad++; } process.exit(bad ? 1 : 0); } if (cmd === "run") { const slug = sel; if (!slug) throw new Error("slug required"); const game = GAMES.find((g) => g.slug === slug); if (!game) throw new Error(`unknown game ${slug}`); const spins = num("spins", 1_000_000); const seedArg = arg("seed"); console.log(`Simulating ${game.name} (${slug}@${game.version}) payScale=${game.payScale} — ${spins.toLocaleString("en-US")} spins`); const res = await simulateParallel(slug, { spins, bet: num("bet", 100), payScale: game.payScale, threads, seed: seedArg ? Number(seedArg) : undefined, onProgress: progressBar }); printResult(res); return; } if (cmd === "calibrate") { const cal = readCalibration(); const list = sel === "arcade" ? RAW_ARCADE_GAMES.filter((g) => g.mode === "instant").map((g) => g.slug) : targets(sel); for (const slug of list) { const arcade = RAW_ARCADE_GAMES.find((x) => x.slug === slug); if (arcade) { if (arcade.mode !== "instant") { console.log(`\n${arcade.name} is a ladder game — RTP is analytic, no calibration needed.`); continue; } console.log(`\nCalibrating ${arcade.name} (${slug}@${arcade.version}, arcade) target ${(arcade.rtp * 100).toFixed(2)}%`); const { payScale, result } = calibrateArcade(arcade, num("spins", 400_000), num("iterations", 4), console.log); cal[slug] = { payScale, version: arcade.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) }; fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n"); console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`); continue; } const g = RAW_GAMES.find((x) => x.slug === slug)!; console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log }); cal[slug] = { payScale, version: g.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) }; fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n"); console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`); } return; } if (cmd === "certify") { fs.mkdirSync(certDir, { recursive: true }); const spins = num("spins", 1_000_000); let failed = 0; const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : sel === "arcade" ? ARCADE_GAMES.map((g) => g.slug) : targets(sel); for (const slug of list) { const arcadeDef = ARCADE_BY_SLUG.get(slug); if (arcadeDef) { console.log(`\nCertifying ${arcadeDef.name} (${slug}@${arcadeDef.version}, arcade ${arcadeDef.mode}) payScale=${arcadeDef.payScale} — ${spins.toLocaleString("en-US")} rounds`); const res = simulateArcade(arcadeDef, { spins }); const report = certifyArcade(arcadeDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) }); fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n"); console.log(formatCertification(report)); if (report.status === "FAIL") failed++; continue; } const crashDef = CRASH_BY_SLUG.get(slug); if (crashDef) { console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`); const res = simulateCrash(crashDef, { spins }); const report = certifyCrash(crashDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) }); fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n"); console.log(formatCertification(report)); if (report.status === "FAIL") failed++; continue; } const g = GAMES.find((x) => x.slug === slug)!; console.log(`\nCertifying ${g.name} (${slug}@${g.version}) payScale=${g.payScale} — ${spins.toLocaleString("en-US")} spins`); const res = await simulateParallel(slug, { spins, payScale: g.payScale, threads, onProgress: progressBar }); const report = certify(g, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) }); fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n"); console.log(formatCertification(report)); if (report.status === "FAIL") failed++; } console.log(`\n${failed === 0 ? "All games PASS" : `${failed} game(s) FAILED`}`); process.exit(failed ? 1 : 0); } console.log("usage: sim [slug|all] [--spins N] [--bet B] [--threads T] [--seed S]"); } function printResult(r: Awaited>) { console.log(` RTP ${(r.observedRtp * 100).toFixed(3)}% (target ${(r.configuredRtp * 100).toFixed(2)}%, dev ${(r.deviation * 100).toFixed(3)}%)`); console.log(` hit rate ${(r.hitRate * 100).toFixed(2)}%`); console.log(` bonus rate ${(r.bonusRate * 100).toFixed(3)}% free spins ${(r.freeSpinRate * 100).toFixed(3)}% jackpots ${(r.jackpotRate * 100).toFixed(4)}%`); console.log(` avg win ${r.averageWin.toFixed(1)} median ${r.medianWin.toFixed(0)} max ${r.maxWin.toLocaleString("en-US")} (${r.maxWinMultiplier.toFixed(0)}×) std ${r.stdDev.toFixed(2)}`); console.log(` capped ${r.cappedRounds} duration ${(r.durationMs / 1000).toFixed(1)}s`); console.log(" distribution:"); for (const b of r.distribution) console.log(` ${b.label.padEnd(10)} ${(b.share * 100).toFixed(3).padStart(8)}% ${b.count.toLocaleString("en-US")}`); const feats = Object.entries(r.featureCounts).sort((a, b) => b[1] - a[1]); if (feats.length) console.log(" features: " + feats.map(([k, v]) => `${k} ${(v / r.spins * 100).toFixed(3)}%`).join(", ")); } main().catch((e) => { console.error(e); process.exit(1); });