/** * Spinza Risk Games ("cash-out" / crash mechanic). * * A round starts, a multiplier grows with time, and the player must cash out * before the secret crash point. The crash multiplier is drawn server-side at * round start from a distribution with a fixed RTP for ANY cash-out strategy: * * P(crash ≥ x) = rtp / x for x ≥ 1, P(instant crash at 1.00) = 1 − rtp * * so the expected return of "cash out at x" is x · rtp / x = rtp. Pacing * (curve shape, boost/cooling windows) never changes the odds — it only * changes how the same multiplier value is reached over time. */ import { createHash, randomBytes } from "node:crypto"; import type { Rng } from "../rng"; import { CryptoRng } from "../rng"; import type { CrashGameDefinition, CrashEvent, CrashRoundSetup } from "./curve"; import { timeForMultiplier } from "./curve"; export * from "./curve"; /* -------------------------------------------------------------- drawing */ export function drawCrashMultiplier(def: CrashGameDefinition, rng: Rng): number { const u = rng.float(); if (u <= 0 || u > def.rtp) return 1; const m = def.rtp / u; return Math.min(def.maxMultiplier, Math.floor(m * 100) / 100); } export function generateEvents(def: CrashGameDefinition, rng: Rng): CrashEvent[] { const cfg = def.events; if (!cfg || !rng.chance(cfg.chance)) return []; const n = 1 + rng.int(cfg.max); const out: CrashEvent[] = []; let cursor = cfg.window[0] * 1000; for (let i = 0; i < n; i++) { const kind = cfg.kinds[rng.weighted(cfg.kinds.map((k) => k.weight))]; const start = cursor + rng.float() * Math.max(500, (cfg.window[1] * 1000 - cursor) / (n - i)); const dur = (kind.duration[0] + rng.float() * (kind.duration[1] - kind.duration[0])) * 1000; out.push({ id: kind.id, label: kind.label, startMs: Math.round(start), endMs: Math.round(start + dur), factor: kind.factor }); cursor = start + dur + 400; if (cursor > cfg.window[1] * 1000) break; } return out; } /** Create the secret setup for a round. `seed` must stay private until the round settles. */ export function setupCrashRound(def: CrashGameDefinition, rng: Rng = new CryptoRng()): CrashRoundSetup { const seed = randomBytes(16).toString("hex"); const crashMultiplier = drawCrashMultiplier(def, rng); const events = generateEvents(def, rng); const commitment = createHash("sha256").update(`${seed}:${crashMultiplier.toFixed(2)}`).digest("hex"); return { crashMultiplier, crashAtMs: timeForMultiplier(def, events, crashMultiplier), seed, commitment, events }; } export function verifyCommitment(seed: string, crashMultiplier: number, commitment: string): boolean { return createHash("sha256").update(`${seed}:${crashMultiplier.toFixed(2)}`).digest("hex") === commitment; } /* ----------------------------------------------------------- simulation */ export interface CrashSimulationResult { game: string; version: string; spins: number; bet: number; configuredRtp: number; observedRtp: number; deviation: number; hitRate: number; bonusRate: number; freeSpinRate: number; jackpotRate: number; wagered: number; returned: number; averageWin: number; medianWin: number; maxWin: number; maxWinMultiplier: number; stdDev: number; distribution: { label: string; min: number; max: number; count: number; share: number }[]; convergence: { spins: number; rtp: number }[]; featureCounts: Record; cappedRounds: number; durationMs: number; } /** * Simulate rounds against a mixed population of players: cash-out targets are * drawn log-uniformly between 1.05× and 50× (plus 10% "greedy" targets up to 500×). * RTP is strategy-independent by construction; the simulation verifies the * implementation end-to-end (drawing, capping, rounding). */ export function simulateCrash(def: CrashGameDefinition, opts: { spins: number; bet?: number; rng?: Rng }): CrashSimulationResult { const rng = opts.rng ?? new CryptoRng(); const bet = opts.bet ?? 100; const started = Date.now(); const n = opts.spins; let returned = 0; let hits = 0; let sum = 0; let sumSq = 0; let maxWin = 0; let capped = 0; const buckets = [ { label: "0×", min: 0, max: 0 }, { label: "1–1.5×", min: 1, max: 1.5 }, { label: "1.5–2×", min: 1.5, max: 2 }, { label: "2–5×", min: 2, max: 5 }, { label: "5–10×", min: 5, max: 10 }, { label: "10–20×", min: 10, max: 20 }, { label: "20–50×", min: 20, max: 50 }, { label: "50–100×", min: 50, max: 100 }, { label: "100–500×", min: 100, max: 500 }, { label: "500×+", min: 500, max: Infinity }, ]; const counts = new Array(buckets.length).fill(0); const convergence: { spins: number; rtp: number }[] = []; const every = Math.max(1, Math.floor(n / 40)); const feature: Record = {}; const sample: number[] = []; for (let i = 0; i < n; i++) { const crash = drawCrashMultiplier(def, rng); if (crash >= def.maxMultiplier) capped++; const greedy = rng.chance(0.1); const target = Math.floor(Math.exp(Math.log(1.05) + rng.float() * (Math.log(greedy ? 500 : 50) - Math.log(1.05))) * 100) / 100; const win = crash > target ? Math.round(bet * target) : 0; const events = generateEvents(def, rng); for (const e of events) feature[e.label] = (feature[e.label] ?? 0) + 1; returned += win; if (win > 0) hits++; const m = win / bet; sum += m; sumSq += m * m; if (win > maxWin) maxWin = win; for (let b = 0; b < buckets.length; b++) if ((buckets[b].max === 0 && m === 0) || (m >= buckets[b].min && m < buckets[b].max && buckets[b].max !== 0)) { counts[b]++; break; } if (sample.length < 100_000) sample.push(m); if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) }); } const mean = sum / n; const variance = Math.max(0, sumSq / n - mean * mean); sample.sort((a, b) => a - b); const observedRtp = returned / (n * bet); return { game: def.slug, version: def.version, spins: n, bet, configuredRtp: def.rtp, observedRtp, deviation: observedRtp - def.rtp, hitRate: hits / n, bonusRate: 0, freeSpinRate: 0, jackpotRate: 0, wagered: n * bet, returned, averageWin: returned / n, medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet, maxWin, maxWinMultiplier: maxWin / bet, stdDev: Math.sqrt(variance), distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })), convergence, featureCounts: feature, cappedRounds: capped, durationMs: Date.now() - started, }; } /* --------------------------------------------------------------- define */ type CrashInput = Omit & Partial>; export function defineCrashGame(input: CrashInput): CrashGameDefinition { return { kind: "crash", minBet: 10, maxBet: 1000, tags: ["risk", "cash-out"], featureNames: ["Cash out anytime", "Auto cash-out", "Provably fair", ...(input.events ? ["Live events"] : [])], ...input, }; } /* ---------------------------------------------------------- certification */ import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation"; /** Certification for crash games: the RTP must match the analytic value for the mixed-strategy population. */ export function certifyCrash(def: CrashGameDefinition, sim: CrashSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport { const se = sim.stdDev / Math.sqrt(sim.spins); const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se)); const checks = [ { name: "definition", pass: def.rtp >= 0.94 && def.rtp <= 0.98 && def.maxMultiplier >= 10, detail: `rtp ${def.rtp}, cap ${def.maxMultiplier}×` }, { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` }, { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` }, { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` }, { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` }, { name: "commitment", pass: (() => { const s = setupCrashRound(def); return verifyCommitment(s.seed, s.crashMultiplier, s.commitment) && s.crashAtMs >= 0; })(), detail: "SHA-256 commit/reveal verified" }, ]; return { game: def.slug, name: def.name, version: def.version, spins: sim.spins, configuredRtp: def.rtp, observedRtp: sim.observedRtp, deviation: sim.deviation, hitRate: sim.hitRate, bonusRate: 0, freeSpinRate: 0, maxWinMultiplier: sim.maxWinMultiplier, stdDev: sim.stdDev, status: checks.every((c) => c.pass) ? "PASS" : "FAIL", checks, certifiedAt: new Date().toISOString(), rules, distribution: sim.distribution, convergence: sim.convergence, featureCounts: sim.featureCounts, durationMs: sim.durationMs, }; }