SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
8.7 KB · 164 lines typescript
Raw Blame History
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