import type { Rng } from "../rng"; import type { ArcadeGameDefinition, LadderAction, LadderEvent, LadderOffer, LadderState } from "./types"; /* ---------------------------------------------------------------- helpers */ const r2 = (x: number) => Math.floor(x * 100) / 100; function fail(msg: string): never { throw new Error(msg); } /** Next multiplier for a step with survival `p` from multiplier `m` (EV-neutral relative to the round RTP). */ export function stepMultiplier(m: number, p: number): number { return m / p; } /* ------------------------------------------------------------- THE VAULT */ export interface VaultConfig { /** Survival per layer (5 layers). */ layers: number[]; /** Digits revealed per layer (sub-steps, cosmetic — the layer's survival is split evenly). */ digitsPerLayer: number; layerNames: string[]; } export function vaultStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState { const cfg = def.config as unknown as VaultConfig; const state: LadderState = { game: def.slug, version: def.version, bet, stage: 0, current: def.rtp, // the round RTP is applied once, up front: securing after step k returns rtp·Π(1/p_i) status: "running", win: 0, offers: [], log: [{ stage: 0, kind: "start", label: "The vault hums. Five layers between you and the core.", multiplierAfter: 0, outcome: "info" }], extra: { digits: [] as number[], layersOpen: 0 }, }; // Mandatory first layer. return vaultAdvance(def, state, rng, { type: "continue" }, true); } function vaultOffers(def: ArcadeGameDefinition, state: LadderState): LadderOffer[] { const cfg = def.config as unknown as VaultConfig; if (state.stage >= cfg.layers.length) return []; const p = cfg.layers[state.stage]; return [ { id: "open", label: `Open layer ${state.stage + 1} — ${cfg.layerNames[state.stage]}`, description: `${Math.round(p * 100)}% chance the lock yields`, survival: p, next: r2(stepMultiplier(state.current, p)), advance: 1, kind: "layer", }, ]; } export function vaultAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState { const cfg = def.config as unknown as VaultConfig; const state: LadderState = structuredClone(s); if (state.status !== "running") return state; if (action.type === "cashout") { if (first || state.stage === 0) fail("cannot secure before the first layer"); state.status = "cashed"; state.win = Math.round(state.bet * state.current); state.log.push({ stage: state.stage, kind: "secure", label: `Secured ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" }); state.offers = []; return state; } const p = cfg.layers[state.stage]; if (p === undefined) fail("vault already open"); // Reveal digits one by one; the layer's survival is split evenly across its digits. const perDigit = Math.pow(p, 1 / cfg.digitsPerLayer); const digits: number[] = []; let bust = false; for (let i = 0; i < cfg.digitsPerLayer; i++) { if (!rng.chance(perDigit)) { bust = true; break; } digits.push(rng.int(10)); } const layerName = cfg.layerNames[state.stage]; if (bust) { state.status = "busted"; state.win = 0; state.log.push({ stage: state.stage + 1, kind: "alarm", label: `Alarm — ${layerName} sealed shut`, detail: `${digits.length}/${cfg.digitsPerLayer} digits matched`, multiplierAfter: 0, outcome: "bust", data: { digits } }); state.offers = []; state.current = 0; return state; } state.current = stepMultiplier(state.current, p); state.stage += 1; (state.extra.digits as number[]).push(...digits); state.extra.layersOpen = state.stage; state.log.push({ stage: state.stage, kind: "layer", label: `${layerName} opened`, detail: digits.join(" "), multiplierAfter: r2(state.current), outcome: "ok", data: { digits } }); if (state.stage >= cfg.layers.length) { state.status = "completed"; state.current = Math.min(state.current, def.maxMultiplier); state.win = Math.round(state.bet * state.current); state.log.push({ stage: state.stage, kind: "jackpot", label: "The core is open — fictional jackpot", multiplierAfter: r2(state.current), outcome: "cash" }); state.offers = []; return state; } state.offers = vaultOffers(def, state); return state; } /* ------------------------------------------------------------- ESCAPE 99 */ export interface EscapeConfig { floors: number; /** Survival by floor band: [uptoFloor, p][] */ bands: [number, number][]; checkpoints: number[]; rooms: { kind: string; label: string; weight: number; detail: string }[]; /** Chance a floor offers two paths (safe vs risky). */ forkChance: number; /** Chance a floor is a portal that jumps 3 floors in one step. */ portalChance: number; } function floorSurvival(cfg: EscapeConfig, floor: number): number { for (const [upto, p] of cfg.bands) if (floor <= upto) return p; return cfg.bands[cfg.bands.length - 1][1]; } export function escapeStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState { const state: LadderState = { game: def.slug, version: def.version, bet, stage: 0, current: def.rtp, status: "running", win: 0, offers: [], log: [{ stage: 0, kind: "start", label: "Floor 1 of 99. The tower wakes up.", multiplierAfter: 0, outcome: "info" }], extra: { checkpointsHit: [] as number[] }, }; return escapeAdvance(def, state, rng, { type: "continue" }, true); } function escapeOffers(def: ArcadeGameDefinition, state: LadderState, rng: Rng): LadderOffer[] { const cfg = def.config as unknown as EscapeConfig; const floor = state.stage + 1; if (floor > cfg.floors) return []; const p = floorSurvival(cfg, floor); const room = cfg.rooms[rng.weighted(cfg.rooms.map((r) => r.weight))]; if (rng.chance(cfg.forkChance) && floor < cfg.floors - 2) { const safe = Math.min(0.98, p + 0.06); const risky = Math.max(0.5, p - 0.18); return [ { id: "safe", label: `Left corridor — ${room.label}`, description: `${Math.round(safe * 100)}% safe`, survival: safe, next: r2(stepMultiplier(state.current, safe)), advance: 1, kind: "fork-safe" }, { id: "risky", label: `Right corridor — ${room.label}`, description: `${Math.round(risky * 100)}% safe, bigger jump`, survival: risky, next: r2(stepMultiplier(state.current, risky)), advance: 1, kind: "fork-risky" }, ]; } if (rng.chance(cfg.portalChance) && floor <= cfg.floors - 3) { const p3 = floorSurvival(cfg, floor) * floorSurvival(cfg, floor + 1) * floorSurvival(cfg, floor + 2); return [{ id: "portal", label: "Portal — skip three floors", description: `${Math.round(p3 * 100)}% to arrive intact`, survival: p3, next: r2(stepMultiplier(state.current, p3)), advance: 3, kind: "portal" }]; } return [{ id: room.kind, label: `Floor ${floor} — ${room.label}`, description: `${Math.round(p * 100)}% safe · ${room.detail}`, survival: p, next: r2(stepMultiplier(state.current, p)), advance: 1, kind: room.kind }]; } export function escapeAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState { const cfg = def.config as unknown as EscapeConfig; const state: LadderState = structuredClone(s); if (state.status !== "running") return state; if (action.type === "cashout") { if (first || state.stage === 0) fail("cannot cash out before the first floor"); state.status = "cashed"; state.win = Math.round(state.bet * state.current); state.log.push({ stage: state.stage, kind: "exit", label: `Exited at floor ${state.stage} with ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" }); state.offers = []; return state; } if (state.offers.length === 0) state.offers = first ? [{ id: "start", label: "Floor 1 — Lobby", description: "", survival: floorSurvival(cfg, 1), next: r2(stepMultiplier(state.current, floorSurvival(cfg, 1))), advance: 1, kind: "room" }] : escapeOffers(def, state, rng); const offer = state.offers.find((o) => o.id === (action.offerId ?? state.offers[0].id)) ?? state.offers[0]; const survived = rng.chance(offer.survival); const floorReached = state.stage + offer.advance; if (!survived) { state.status = "busted"; state.current = 0; state.win = 0; state.log.push({ stage: floorReached, kind: offer.kind, label: bustLabel(offer.kind), detail: offer.label, multiplierAfter: 0, outcome: "bust" }); state.offers = []; return state; } state.current = Math.min(def.maxMultiplier, stepMultiplier(state.current, offer.survival)); state.stage = floorReached; const cp = cfg.checkpoints.includes(state.stage); if (cp) (state.extra.checkpointsHit as number[]).push(state.stage); state.log.push({ stage: state.stage, kind: offer.kind, label: offer.label, detail: cp ? "Checkpoint reached" : undefined, multiplierAfter: r2(state.current), outcome: "ok", data: { checkpoint: cp } }); if (state.stage >= cfg.floors) { state.status = "completed"; state.win = Math.round(state.bet * state.current); state.log.push({ stage: state.stage, kind: "summit", label: "Floor 99 — the tower opens to the sky", multiplierAfter: r2(state.current), outcome: "cash" }); state.offers = []; return state; } state.offers = escapeOffers(def, state, rng); return state; } function bustLabel(kind: string): string { return ( { enemy: "Caught by the guardian", trap: "Trap triggered", chest: "The chest was a mimic", portal: "Lost in the portal", "fork-safe": "The corridor collapsed", "fork-risky": "The corridor collapsed", multiplier: "The rune backfired", room: "The door locked behind you", }[kind] ?? "Run over" ); } /* ---------------------------------------------------------------- driver */ export function ladderStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState { if (def.slug === "the-vault") return vaultStart(def, bet, rng); if (def.slug === "escape-99") return escapeStart(def, bet, rng); fail(`unknown ladder game ${def.slug}`); } export function ladderAdvance(def: ArcadeGameDefinition, state: LadderState, rng: Rng, action: LadderAction): LadderState { if (def.slug === "the-vault") return vaultAdvance(def, state, rng, action); if (def.slug === "escape-99") return escapeAdvance(def, state, rng, action); fail(`unknown ladder game ${def.slug}`); } /** Public projection: never leaks anything beyond what the player may see. */ export function ladderView(state: LadderState) { return { game: state.game, version: state.version, bet: state.bet, stage: state.stage, current: r2(state.current), status: state.status, win: state.win, offers: state.offers, log: state.log, extra: state.extra, canCashout: state.status === "running" && state.stage > 0, }; } /* ------------------------------------------------------------- simulation */ /** * Mixed strategy population: most simulated players stop after a few stages * (geometric-like targets, mean ≈ 6 stages), 3% are "greedy" and pick any * target up to the top. RTP is strategy-independent by construction; the light * tail keeps the simulation variance small enough to certify in a few million rounds. */ export function simulateLadderRound(def: ArcadeGameDefinition, bet: number, rng: Rng): number { let state = ladderStart(def, bet, rng); const maxStage = def.slug === "the-vault" ? 5 : 99; const target = rng.chance(0.03) ? 1 + rng.int(maxStage) : 1 + Math.min(maxStage - 1, Math.floor(-Math.log(1 - rng.float()) * 6)); // stop after `target` stages const preferRisky = rng.chance(0.5); while (state.status === "running") { if (state.stage >= target) { state = ladderAdvance(def, state, rng, { type: "cashout" }); break; } const offer = state.offers.length > 1 ? (preferRisky ? state.offers[1] : state.offers[0]) : state.offers[0]; state = ladderAdvance(def, state, rng, { type: "continue", offerId: offer?.id }); } return state.win; }