TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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, certifyCrash, certifyArcade, calibrateArcade, formatCertification, simulateCrash, simulateArcade, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core";14import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_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 const list = sel === "arcade" ? RAW_ARCADE_GAMES.filter((g) => g.mode === "instant").map((g) => g.slug) : targets(sel);83 for (const slug of list) {84 const arcade = RAW_ARCADE_GAMES.find((x) => x.slug === slug);85 if (arcade) {86 if (arcade.mode !== "instant") {87 console.log(`\n${arcade.name} is a ladder game — RTP is analytic, no calibration needed.`);88 continue;89 }90 console.log(`\nCalibrating ${arcade.name} (${slug}@${arcade.version}, arcade) target ${(arcade.rtp * 100).toFixed(2)}%`);91 const { payScale, result } = calibrateArcade(arcade, num("spins", 400_000), num("iterations", 4), console.log);92 cal[slug] = { payScale, version: arcade.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) };93 fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n");94 console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`);95 continue;96 }97 const g = RAW_GAMES.find((x) => x.slug === slug)!;98 console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`);99 const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log });100 cal[slug] = { payScale, version: g.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) };101 fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n");102 console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`);103 }104 return;105 }106107 if (cmd === "certify") {108 fs.mkdirSync(certDir, { recursive: true });109 const spins = num("spins", 1_000_000);110 let failed = 0;111 const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : sel === "arcade" ? ARCADE_GAMES.map((g) => g.slug) : targets(sel);112 for (const slug of list) {113 const arcadeDef = ARCADE_BY_SLUG.get(slug);114 if (arcadeDef) {115 console.log(`\nCertifying ${arcadeDef.name} (${slug}@${arcadeDef.version}, arcade ${arcadeDef.mode}) payScale=${arcadeDef.payScale} — ${spins.toLocaleString("en-US")} rounds`);116 const res = simulateArcade(arcadeDef, { spins });117 const report = certifyArcade(arcadeDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });118 fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");119 console.log(formatCertification(report));120 if (report.status === "FAIL") failed++;121 continue;122 }123 const crashDef = CRASH_BY_SLUG.get(slug);124 if (crashDef) {125 console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`);126 const res = simulateCrash(crashDef, { spins });127 const report = certifyCrash(crashDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });128 fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");129 console.log(formatCertification(report));130 if (report.status === "FAIL") failed++;131 continue;132 }133 const g = GAMES.find((x) => x.slug === slug)!;134 console.log(`\nCertifying ${g.name} (${slug}@${g.version}) payScale=${g.payScale} — ${spins.toLocaleString("en-US")} spins`);135 const res = await simulateParallel(slug, { spins, payScale: g.payScale, threads, onProgress: progressBar });136 const report = certify(g, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });137 fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");138 console.log(formatCertification(report));139 if (report.status === "FAIL") failed++;140 }141 console.log(`\n${failed === 0 ? "All games PASS" : `${failed} game(s) FAILED`}`);142 process.exit(failed ? 1 : 0);143 }144145 console.log("usage: sim <validate|run|calibrate|certify> [slug|all] [--spins N] [--bet B] [--threads T] [--seed S]");146}147148function printResult(r: Awaited<ReturnType<typeof simulateParallel>>) {149 console.log(` RTP ${(r.observedRtp * 100).toFixed(3)}% (target ${(r.configuredRtp * 100).toFixed(2)}%, dev ${(r.deviation * 100).toFixed(3)}%)`);150 console.log(` hit rate ${(r.hitRate * 100).toFixed(2)}%`);151 console.log(` bonus rate ${(r.bonusRate * 100).toFixed(3)}% free spins ${(r.freeSpinRate * 100).toFixed(3)}% jackpots ${(r.jackpotRate * 100).toFixed(4)}%`);152 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)}`);153 console.log(` capped ${r.cappedRounds} duration ${(r.durationMs / 1000).toFixed(1)}s`);154 console.log(" distribution:");155 for (const b of r.distribution) console.log(` ${b.label.padEnd(10)} ${(b.share * 100).toFixed(3).padStart(8)}% ${b.count.toLocaleString("en-US")}`);156 const feats = Object.entries(r.featureCounts).sort((a, b) => b[1] - a[1]);157 if (feats.length) console.log(" features: " + feats.map(([k, v]) => `${k} ${(v / r.spins * 100).toFixed(3)}%`).join(", "));158}159160main().catch((e) => {161 console.error(e);162 process.exit(1);163});164