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%
9.2 KB · 229 lines typescript
Raw Blame History
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