TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1/**2 * Spinza Risk Games ("cash-out" / crash mechanic).3 *4 * A round starts, a multiplier grows with time, and the player must cash out5 * before the secret crash point. The crash multiplier is drawn server-side at6 * round start from a distribution with a fixed RTP for ANY cash-out strategy:7 *8 * P(crash ≥ x) = rtp / x for x ≥ 1, P(instant crash at 1.00) = 1 − rtp9 *10 * so the expected return of "cash out at x" is x · rtp / x = rtp. Pacing11 * (curve shape, boost/cooling windows) never changes the odds — it only12 * changes how the same multiplier value is reached over time.13 */14import { createHash, randomBytes } from "node:crypto";15import type { Rng } from "../rng";16import { CryptoRng } from "../rng";1718import type { CrashGameDefinition, CrashEvent, CrashRoundSetup } from "./curve";19import { timeForMultiplier } from "./curve";20export * from "./curve";2122/* -------------------------------------------------------------- drawing */2324export function drawCrashMultiplier(def: CrashGameDefinition, rng: Rng): number {25 const u = rng.float();26 if (u <= 0 || u > def.rtp) return 1;27 const m = def.rtp / u;28 return Math.min(def.maxMultiplier, Math.floor(m * 100) / 100);29}3031export function generateEvents(def: CrashGameDefinition, rng: Rng): CrashEvent[] {32 const cfg = def.events;33 if (!cfg || !rng.chance(cfg.chance)) return [];34 const n = 1 + rng.int(cfg.max);35 const out: CrashEvent[] = [];36 let cursor = cfg.window[0] * 1000;37 for (let i = 0; i < n; i++) {38 const kind = cfg.kinds[rng.weighted(cfg.kinds.map((k) => k.weight))];39 const start = cursor + rng.float() * Math.max(500, (cfg.window[1] * 1000 - cursor) / (n - i));40 const dur = (kind.duration[0] + rng.float() * (kind.duration[1] - kind.duration[0])) * 1000;41 out.push({ id: kind.id, label: kind.label, startMs: Math.round(start), endMs: Math.round(start + dur), factor: kind.factor });42 cursor = start + dur + 400;43 if (cursor > cfg.window[1] * 1000) break;44 }45 return out;46}4748/** Create the secret setup for a round. `seed` must stay private until the round settles. */49export function setupCrashRound(def: CrashGameDefinition, rng: Rng = new CryptoRng()): CrashRoundSetup {50 const seed = randomBytes(16).toString("hex");51 const crashMultiplier = drawCrashMultiplier(def, rng);52 const events = generateEvents(def, rng);53 const commitment = createHash("sha256").update(`${seed}:${crashMultiplier.toFixed(2)}`).digest("hex");54 return { crashMultiplier, crashAtMs: timeForMultiplier(def, events, crashMultiplier), seed, commitment, events };55}5657export function verifyCommitment(seed: string, crashMultiplier: number, commitment: string): boolean {58 return createHash("sha256").update(`${seed}:${crashMultiplier.toFixed(2)}`).digest("hex") === commitment;59}6061/* ----------------------------------------------------------- simulation */6263export interface CrashSimulationResult {64 game: string;65 version: string;66 spins: number;67 bet: number;68 configuredRtp: number;69 observedRtp: number;70 deviation: number;71 hitRate: number;72 bonusRate: number;73 freeSpinRate: number;74 jackpotRate: number;75 wagered: number;76 returned: number;77 averageWin: number;78 medianWin: number;79 maxWin: number;80 maxWinMultiplier: number;81 stdDev: number;82 distribution: { label: string; min: number; max: number; count: number; share: number }[];83 convergence: { spins: number; rtp: number }[];84 featureCounts: Record<string, number>;85 cappedRounds: number;86 durationMs: number;87}8889/**90 * Simulate rounds against a mixed population of players: cash-out targets are91 * drawn log-uniformly between 1.05× and 50× (plus 10% "greedy" targets up to 500×).92 * RTP is strategy-independent by construction; the simulation verifies the93 * implementation end-to-end (drawing, capping, rounding).94 */95export function simulateCrash(def: CrashGameDefinition, opts: { spins: number; bet?: number; rng?: Rng }): CrashSimulationResult {96 const rng = opts.rng ?? new CryptoRng();97 const bet = opts.bet ?? 100;98 const started = Date.now();99 const n = opts.spins;100 let returned = 0;101 let hits = 0;102 let sum = 0;103 let sumSq = 0;104 let maxWin = 0;105 let capped = 0;106 const buckets = [107 { label: "0×", min: 0, max: 0 },108 { label: "1–1.5×", min: 1, max: 1.5 },109 { label: "1.5–2×", min: 1.5, max: 2 },110 { label: "2–5×", min: 2, max: 5 },111 { label: "5–10×", min: 5, max: 10 },112 { label: "10–20×", min: 10, max: 20 },113 { label: "20–50×", min: 20, max: 50 },114 { label: "50–100×", min: 50, max: 100 },115 { label: "100–500×", min: 100, max: 500 },116 { label: "500×+", min: 500, max: Infinity },117 ];118 const counts = new Array(buckets.length).fill(0);119 const convergence: { spins: number; rtp: number }[] = [];120 const every = Math.max(1, Math.floor(n / 40));121 const feature: Record<string, number> = {};122 const sample: number[] = [];123 for (let i = 0; i < n; i++) {124 const crash = drawCrashMultiplier(def, rng);125 if (crash >= def.maxMultiplier) capped++;126 const greedy = rng.chance(0.1);127 const target = Math.floor(Math.exp(Math.log(1.05) + rng.float() * (Math.log(greedy ? 500 : 50) - Math.log(1.05))) * 100) / 100;128 const win = crash > target ? Math.round(bet * target) : 0;129 const events = generateEvents(def, rng);130 for (const e of events) feature[e.label] = (feature[e.label] ?? 0) + 1;131 returned += win;132 if (win > 0) hits++;133 const m = win / bet;134 sum += m;135 sumSq += m * m;136 if (win > maxWin) maxWin = win;137 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)) {138 counts[b]++;139 break;140 }141 if (sample.length < 100_000) sample.push(m);142 if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) });143 }144 const mean = sum / n;145 const variance = Math.max(0, sumSq / n - mean * mean);146 sample.sort((a, b) => a - b);147 const observedRtp = returned / (n * bet);148 return {149 game: def.slug,150 version: def.version,151 spins: n,152 bet,153 configuredRtp: def.rtp,154 observedRtp,155 deviation: observedRtp - def.rtp,156 hitRate: hits / n,157 bonusRate: 0,158 freeSpinRate: 0,159 jackpotRate: 0,160 wagered: n * bet,161 returned,162 averageWin: returned / n,163 medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet,164 maxWin,165 maxWinMultiplier: maxWin / bet,166 stdDev: Math.sqrt(variance),167 distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })),168 convergence,169 featureCounts: feature,170 cappedRounds: capped,171 durationMs: Date.now() - started,172 };173}174175/* --------------------------------------------------------------- define */176177type CrashInput = Omit<CrashGameDefinition, "kind" | "featureNames" | "tags" | "minBet" | "maxBet"> & Partial<Pick<CrashGameDefinition, "featureNames" | "tags" | "minBet" | "maxBet">>;178179export function defineCrashGame(input: CrashInput): CrashGameDefinition {180 return {181 kind: "crash",182 minBet: 10,183 maxBet: 1000,184 tags: ["risk", "cash-out"],185 featureNames: ["Cash out anytime", "Auto cash-out", "Provably fair", ...(input.events ? ["Live events"] : [])],186 ...input,187 };188}189190/* ---------------------------------------------------------- certification */191192import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation";193194/** Certification for crash games: the RTP must match the analytic value for the mixed-strategy population. */195export function certifyCrash(def: CrashGameDefinition, sim: CrashSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport {196 const se = sim.stdDev / Math.sqrt(sim.spins);197 const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se));198 const checks = [199 { name: "definition", pass: def.rtp >= 0.94 && def.rtp <= 0.98 && def.maxMultiplier >= 10, detail: `rtp ${def.rtp}, cap ${def.maxMultiplier}×` },200 { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` },201 { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` },202 { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` },203 { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` },204 { name: "commitment", pass: (() => { const s = setupCrashRound(def); return verifyCommitment(s.seed, s.crashMultiplier, s.commitment) && s.crashAtMs >= 0; })(), detail: "SHA-256 commit/reveal verified" },205 ];206 return {207 game: def.slug,208 name: def.name,209 version: def.version,210 spins: sim.spins,211 configuredRtp: def.rtp,212 observedRtp: sim.observedRtp,213 deviation: sim.deviation,214 hitRate: sim.hitRate,215 bonusRate: 0,216 freeSpinRate: 0,217 maxWinMultiplier: sim.maxWinMultiplier,218 stdDev: sim.stdDev,219 status: checks.every((c) => c.pass) ? "PASS" : "FAIL",220 checks,221 certifiedAt: new Date().toISOString(),222 rules,223 distribution: sim.distribution,224 convergence: sim.convergence,225 featureCounts: sim.featureCounts,226 durationMs: sim.durationMs,227 };228}229