1#!/usr/bin/env tsx2/**3 * Spinza simulator CLI.4 *5 * pnpm sim run <slug> [--spins 1000000] [--bet 100] [--threads 8] [--seed 42]6 * pnpm sim calibrate <slug|all> [--spins 400000] [--iterations 4]7 * pnpm sim certify <slug|all> [--spins 1000000]8 * pnpm sim validate9 */10import fs from "node:fs";11import path from "node:path";12import { fileURLToPath } from "node:url";13import { certify, formatCertification, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core";14import { RAW_GAMES, GAMES } from "@spinza/games";15import { calibrate, simulateParallel } from "./index";1617const here = path.dirname(fileURLToPath(import.meta.url));18const gamesDir = path.resolve(here, "../../../games");19const calibrationPath = path.join(gamesDir, "src/calibration.json");20const certDir = path.join(gamesDir, "certifications");2122function arg(name: string, def?: string): string | undefined {23 const i = process.argv.indexOf(`--${name}`);24 return i >= 0 ? process.argv[i + 1] : def;25}26function num(name: string, def: number): number {27 const v = arg(name);28 return v ? Number(v.replace(/[_,]/g, "")) : def;29}3031function targets(sel: string | undefined): string[] {32 if (!sel || sel === "all") return RAW_GAMES.map((g) => g.slug);33 return sel.split(",");34}3536function readCalibration(): Record<string, { payScale: number; version: string; calibratedAt: string; spins: number; observedRtp: number }> {37 try {38 return JSON.parse(fs.readFileSync(calibrationPath, "utf8"));39 } catch {40 return {};41 }42}4344function progressBar(done: number, total: number): void {45 const pct = Math.min(1, done / total);46 const w = 30;47 const bar = "█".repeat(Math.round(pct * w)).padEnd(w, "░");48 process.stdout.write(`\r [${bar}] ${(pct * 100).toFixed(0)}% ${done.toLocaleString("en-US")}/${total.toLocaleString("en-US")}`);49 if (done >= total) process.stdout.write("\n");50}5152async function main() {53 const [cmd, sel] = process.argv.slice(2);54 const threads = num("threads", 0) || undefined;5556 if (cmd === "validate") {57 let bad = 0;58 for (const g of RAW_GAMES) {59 const issues = validateDefinition(g);60 const errors = issues.filter((i) => i.level === "error");61 console.log(`${errors.length ? "✗" : "✓"} ${g.slug}@${g.version} ${issues.map((i) => `[${i.level}] ${i.message}`).join(" | ")}`);62 if (errors.length) bad++;63 }64 process.exit(bad ? 1 : 0);65 }6667 if (cmd === "run") {68 const slug = sel;69 if (!slug) throw new Error("slug required");70 const game = GAMES.find((g) => g.slug === slug);71 if (!game) throw new Error(`unknown game ${slug}`);72 const spins = num("spins", 1_000_000);73 const seedArg = arg("seed");74 console.log(`Simulating ${game.name} (${slug}@${game.version}) payScale=${game.payScale} — ${spins.toLocaleString("en-US")} spins`);75 const res = await simulateParallel(slug, { spins, bet: num("bet", 100), payScale: game.payScale, threads, seed: seedArg ? Number(seedArg) : undefined, onProgress: progressBar });76 printResult(res);77 return;78 }7980 if (cmd === "calibrate") {81 const cal = readCalibration();82 for (const slug of targets(sel)) {83 const g = RAW_GAMES.find((x) => x.slug === slug)!;84 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`);85 const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log });86 cal[slug] = { payScale, version: g.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) };87 fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n");88 console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`);89 }90 return;91 }9293 if (cmd === "certify") {94 fs.mkdirSync(certDir, { recursive: true });95 const spins = num("spins", 1_000_000);96 let failed = 0;97 for (const slug of targets(sel)) {98 const g = GAMES.find((x) => x.slug === slug)!;99 console.log(`\nCertifying ${g.name} (${slug}@${g.version}) payScale=${g.payScale} — ${spins.toLocaleString("en-US")} spins`);100 const res = await simulateParallel(slug, { spins, payScale: g.payScale, threads, onProgress: progressBar });101 const report = certify(g, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });102 fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");103 console.log(formatCertification(report));104 if (report.status === "FAIL") failed++;105 }106 console.log(`\n${failed === 0 ? "All games PASS" : `${failed} game(s) FAILED`}`);107 process.exit(failed ? 1 : 0);108 }109110 console.log("usage: sim <validate|run|calibrate|certify> [slug|all] [--spins N] [--bet B] [--threads T] [--seed S]");111}112113function printResult(r: Awaited<ReturnType<typeof simulateParallel>>) {114 console.log(` RTP ${(r.observedRtp * 100).toFixed(3)}% (target ${(r.configuredRtp * 100).toFixed(2)}%, dev ${(r.deviation * 100).toFixed(3)}%)`);115 console.log(` hit rate ${(r.hitRate * 100).toFixed(2)}%`);116 console.log(` bonus rate ${(r.bonusRate * 100).toFixed(3)}% free spins ${(r.freeSpinRate * 100).toFixed(3)}% jackpots ${(r.jackpotRate * 100).toFixed(4)}%`);117 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)}`);118 console.log(` capped ${r.cappedRounds} duration ${(r.durationMs / 1000).toFixed(1)}s`);119 console.log(" distribution:");120 for (const b of r.distribution) console.log(` ${b.label.padEnd(10)} ${(b.share * 100).toFixed(3).padStart(8)}% ${b.count.toLocaleString("en-US")}`);121 const feats = Object.entries(r.featureCounts).sort((a, b) => b[1] - a[1]);122 if (feats.length) console.log(" features: " + feats.map(([k, v]) => `${k} ${(v / r.spins * 100).toFixed(3)}%`).join(", "));123}124125main().catch((e) => {126 console.error(e);127 process.exit(1);128});129