SPB Git

spb/spinza

Public
6.1 KB · 129 lines typescript
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
Files · 3b4179a710ab1cd5f12ca7cfd253b81674f1521e
Menu

Theme