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%
7.8 KB · 186 lines typescript
Raw Blame History
1import type { Rng } from "../rng";2import { CryptoRng } from "../rng";3import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation";4import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";5import { resolveDropzone, randomDropzoneInput } from "./dropzone";6import { resolveGridbreak, randomGridbreakInput } from "./gridbreak";7import { resolveOrbit, randomOrbitInput } from "./orbit";8import { simulateLadderRound } from "./ladder";9import type { CrashSimulationResult } from "../crash";1011export * from "./types";12export * from "./ladder";13export * from "./dropzone";14export * from "./gridbreak";15export * from "./orbit";1617/* ------------------------------------------------------------ dispatch */1819export function resolveInstant(def: ArcadeGameDefinition, bet: number, input: Record<string, unknown>, rng: Rng = new CryptoRng()): ArcadeOutcome {20  switch (def.presentation.scene) {21    case "dropzone":22      return resolveDropzone(def, bet, rng, input as never);23    case "gridbreak":24      return resolveGridbreak(def, bet, rng, input as never);25    case "orbit":26      return resolveOrbit(def, bet, rng, input as never);27    default:28      throw new Error(`${def.slug} is not an instant game`);29  }30}3132export function randomInput(def: ArcadeGameDefinition, rng: Rng): Record<string, unknown> {33  switch (def.presentation.scene) {34    case "dropzone":35      return randomDropzoneInput(def, rng) as unknown as Record<string, unknown>;36    case "gridbreak":37      return randomGridbreakInput(def, rng);38    case "orbit":39      return randomOrbitInput(def, rng);40    default:41      return {};42  }43}4445/* ---------------------------------------------------------- simulation */4647export type ArcadeSimulationResult = CrashSimulationResult;4849export function simulateArcade(def: ArcadeGameDefinition, opts: { spins: number; bet?: number; rng?: Rng; payScale?: number }): ArcadeSimulationResult {50  const rng = opts.rng ?? new CryptoRng();51  const bet = opts.bet ?? 100;52  const d = opts.payScale !== undefined ? { ...def, payScale: opts.payScale } : def;53  const n = opts.spins;54  const started = Date.now();55  let returned = 0;56  let hits = 0;57  let sum = 0;58  let sumSq = 0;59  let maxWin = 0;60  let capped = 0;61  const buckets = [62    { label: "0×", min: 0, max: 0 },63    { label: "0–1×", min: 0.000001, max: 1 },64    { label: "1–2×", min: 1, max: 2 },65    { label: "2–5×", min: 2, max: 5 },66    { label: "5–10×", min: 5, max: 10 },67    { label: "10–20×", min: 10, max: 20 },68    { label: "20–50×", min: 20, max: 50 },69    { label: "50–100×", min: 50, max: 100 },70    { label: "100–500×", min: 100, max: 500 },71    { label: "500×+", min: 500, max: Infinity },72  ];73  const counts = new Array(buckets.length).fill(0);74  const feature: Record<string, number> = {};75  const convergence: { spins: number; rtp: number }[] = [];76  const every = Math.max(1, Math.floor(n / 40));77  const sample: number[] = [];78  for (let i = 0; i < n; i++) {79    let win: number;80    if (d.mode === "ladder") win = simulateLadderRound(d, bet, rng);81    else {82      const out = resolveInstant(d, bet, randomInput(d, rng), rng);83      win = out.totalWin;84      if (out.capped) capped++;85      for (const f of out.features) feature[f] = (feature[f] ?? 0) + 1;86    }87    returned += win;88    if (win > 0) hits++;89    const m = win / bet;90    sum += m;91    sumSq += m * m;92    if (win > maxWin) maxWin = win;93    for (let b = 0; b < buckets.length; b++) if ((buckets[b].max === 0 && m === 0) || (buckets[b].max !== 0 && m >= buckets[b].min && m < buckets[b].max)) {94      counts[b]++;95      break;96    }97    if (sample.length < 100_000) sample.push(m);98    if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) });99  }100  const mean = sum / n;101  const variance = Math.max(0, sumSq / n - mean * mean);102  sample.sort((a, b) => a - b);103  const observedRtp = returned / (n * bet);104  return {105    game: d.slug,106    version: d.version,107    spins: n,108    bet,109    configuredRtp: d.rtp,110    observedRtp,111    deviation: observedRtp - d.rtp,112    hitRate: hits / n,113    bonusRate: 0,114    freeSpinRate: 0,115    jackpotRate: 0,116    wagered: n * bet,117    returned,118    averageWin: returned / n,119    medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet,120    maxWin,121    maxWinMultiplier: maxWin / bet,122    stdDev: Math.sqrt(variance),123    distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })),124    convergence,125    featureCounts: feature,126    cappedRounds: capped,127    durationMs: Date.now() - started,128  };129}130131/** Find the payScale bringing an instant game to its target RTP (ladders are analytic). */132export function calibrateArcade(def: ArcadeGameDefinition, spins = 400_000, iterations = 4, log?: (s: string) => void): { payScale: number; result: ArcadeSimulationResult } {133  let payScale = 1;134  let result = simulateArcade(def, { spins: Math.max(100_000, Math.floor(spins / 4)), payScale });135  log?.(`  iter 0: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`);136  for (let i = 1; i <= iterations; i++) {137    const se = result.stdDev / Math.sqrt(result.spins);138    if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break;139    payScale = Math.min(10, Math.max(0.01, payScale * (1 + (def.rtp / result.observedRtp - 1) * 0.9)));140    result = simulateArcade(def, { spins, payScale });141    log?.(`  iter ${i}: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`);142  }143  return { payScale: Number(payScale.toFixed(4)), result };144}145146export function certifyArcade(def: ArcadeGameDefinition, sim: ArcadeSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport {147  const se = sim.stdDev / Math.sqrt(sim.spins);148  const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se));149  const checks = [150    { name: "definition", pass: def.rtp >= 0.94 && def.rtp <= 0.98 && def.maxMultiplier >= 10 && def.payScale > 0, detail: `rtp ${def.rtp}, cap ${def.maxMultiplier}×, payScale ${def.payScale}` },151    { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` },152    { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` },153    { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` },154    { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` },155    { name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` },156  ];157  return {158    game: def.slug,159    name: def.name,160    version: def.version,161    spins: sim.spins,162    configuredRtp: def.rtp,163    observedRtp: sim.observedRtp,164    deviation: sim.deviation,165    hitRate: sim.hitRate,166    bonusRate: 0,167    freeSpinRate: 0,168    maxWinMultiplier: sim.maxWinMultiplier,169    stdDev: sim.stdDev,170    status: checks.every((c) => c.pass) ? "PASS" : "FAIL",171    checks,172    certifiedAt: new Date().toISOString(),173    rules,174    distribution: sim.distribution,175    convergence: sim.convergence,176    featureCounts: sim.featureCounts,177    durationMs: sim.durationMs,178  };179}180181type ArcadeInput = Omit<ArcadeGameDefinition, "kind" | "featureNames" | "tags" | "minBet" | "maxBet" | "payScale"> & Partial<Pick<ArcadeGameDefinition, "featureNames" | "tags" | "minBet" | "maxBet" | "payScale">>;182183export function defineArcadeGame(input: ArcadeInput): ArcadeGameDefinition {184  return { kind: "arcade", minBet: 10, maxBet: 1000, payScale: 1, tags: ["original", "beyond-slots"], featureNames: [], ...input };185}186