TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { Rng } from "../rng";2import type { ArcadeGameDefinition, LadderAction, LadderEvent, LadderOffer, LadderState } from "./types";34/* ---------------------------------------------------------------- helpers */56const r2 = (x: number) => Math.floor(x * 100) / 100;78function fail(msg: string): never {9 throw new Error(msg);10}1112/** Next multiplier for a step with survival `p` from multiplier `m` (EV-neutral relative to the round RTP). */13export function stepMultiplier(m: number, p: number): number {14 return m / p;15}1617/* ------------------------------------------------------------- THE VAULT */1819export interface VaultConfig {20 /** Survival per layer (5 layers). */21 layers: number[];22 /** Digits revealed per layer (sub-steps, cosmetic — the layer's survival is split evenly). */23 digitsPerLayer: number;24 layerNames: string[];25}2627export function vaultStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState {28 const cfg = def.config as unknown as VaultConfig;29 const state: LadderState = {30 game: def.slug,31 version: def.version,32 bet,33 stage: 0,34 current: def.rtp, // the round RTP is applied once, up front: securing after step k returns rtp·Π(1/p_i)35 status: "running",36 win: 0,37 offers: [],38 log: [{ stage: 0, kind: "start", label: "The vault hums. Five layers between you and the core.", multiplierAfter: 0, outcome: "info" }],39 extra: { digits: [] as number[], layersOpen: 0 },40 };41 // Mandatory first layer.42 return vaultAdvance(def, state, rng, { type: "continue" }, true);43}4445function vaultOffers(def: ArcadeGameDefinition, state: LadderState): LadderOffer[] {46 const cfg = def.config as unknown as VaultConfig;47 if (state.stage >= cfg.layers.length) return [];48 const p = cfg.layers[state.stage];49 return [50 {51 id: "open",52 label: `Open layer ${state.stage + 1} — ${cfg.layerNames[state.stage]}`,53 description: `${Math.round(p * 100)}% chance the lock yields`,54 survival: p,55 next: r2(stepMultiplier(state.current, p)),56 advance: 1,57 kind: "layer",58 },59 ];60}6162export function vaultAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState {63 const cfg = def.config as unknown as VaultConfig;64 const state: LadderState = structuredClone(s);65 if (state.status !== "running") return state;66 if (action.type === "cashout") {67 if (first || state.stage === 0) fail("cannot secure before the first layer");68 state.status = "cashed";69 state.win = Math.round(state.bet * state.current);70 state.log.push({ stage: state.stage, kind: "secure", label: `Secured ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" });71 state.offers = [];72 return state;73 }74 const p = cfg.layers[state.stage];75 if (p === undefined) fail("vault already open");76 // Reveal digits one by one; the layer's survival is split evenly across its digits.77 const perDigit = Math.pow(p, 1 / cfg.digitsPerLayer);78 const digits: number[] = [];79 let bust = false;80 for (let i = 0; i < cfg.digitsPerLayer; i++) {81 if (!rng.chance(perDigit)) {82 bust = true;83 break;84 }85 digits.push(rng.int(10));86 }87 const layerName = cfg.layerNames[state.stage];88 if (bust) {89 state.status = "busted";90 state.win = 0;91 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 } });92 state.offers = [];93 state.current = 0;94 return state;95 }96 state.current = stepMultiplier(state.current, p);97 state.stage += 1;98 (state.extra.digits as number[]).push(...digits);99 state.extra.layersOpen = state.stage;100 state.log.push({ stage: state.stage, kind: "layer", label: `${layerName} opened`, detail: digits.join(" "), multiplierAfter: r2(state.current), outcome: "ok", data: { digits } });101 if (state.stage >= cfg.layers.length) {102 state.status = "completed";103 state.current = Math.min(state.current, def.maxMultiplier);104 state.win = Math.round(state.bet * state.current);105 state.log.push({ stage: state.stage, kind: "jackpot", label: "The core is open — fictional jackpot", multiplierAfter: r2(state.current), outcome: "cash" });106 state.offers = [];107 return state;108 }109 state.offers = vaultOffers(def, state);110 return state;111}112113/* ------------------------------------------------------------- ESCAPE 99 */114115export interface EscapeConfig {116 floors: number;117 /** Survival by floor band: [uptoFloor, p][] */118 bands: [number, number][];119 checkpoints: number[];120 rooms: { kind: string; label: string; weight: number; detail: string }[];121 /** Chance a floor offers two paths (safe vs risky). */122 forkChance: number;123 /** Chance a floor is a portal that jumps 3 floors in one step. */124 portalChance: number;125}126127function floorSurvival(cfg: EscapeConfig, floor: number): number {128 for (const [upto, p] of cfg.bands) if (floor <= upto) return p;129 return cfg.bands[cfg.bands.length - 1][1];130}131132export function escapeStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState {133 const state: LadderState = {134 game: def.slug,135 version: def.version,136 bet,137 stage: 0,138 current: def.rtp,139 status: "running",140 win: 0,141 offers: [],142 log: [{ stage: 0, kind: "start", label: "Floor 1 of 99. The tower wakes up.", multiplierAfter: 0, outcome: "info" }],143 extra: { checkpointsHit: [] as number[] },144 };145 return escapeAdvance(def, state, rng, { type: "continue" }, true);146}147148function escapeOffers(def: ArcadeGameDefinition, state: LadderState, rng: Rng): LadderOffer[] {149 const cfg = def.config as unknown as EscapeConfig;150 const floor = state.stage + 1;151 if (floor > cfg.floors) return [];152 const p = floorSurvival(cfg, floor);153 const room = cfg.rooms[rng.weighted(cfg.rooms.map((r) => r.weight))];154 if (rng.chance(cfg.forkChance) && floor < cfg.floors - 2) {155 const safe = Math.min(0.98, p + 0.06);156 const risky = Math.max(0.5, p - 0.18);157 return [158 { 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" },159 { 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" },160 ];161 }162 if (rng.chance(cfg.portalChance) && floor <= cfg.floors - 3) {163 const p3 = floorSurvival(cfg, floor) * floorSurvival(cfg, floor + 1) * floorSurvival(cfg, floor + 2);164 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" }];165 }166 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 }];167}168169export function escapeAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState {170 const cfg = def.config as unknown as EscapeConfig;171 const state: LadderState = structuredClone(s);172 if (state.status !== "running") return state;173 if (action.type === "cashout") {174 if (first || state.stage === 0) fail("cannot cash out before the first floor");175 state.status = "cashed";176 state.win = Math.round(state.bet * state.current);177 state.log.push({ stage: state.stage, kind: "exit", label: `Exited at floor ${state.stage} with ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" });178 state.offers = [];179 return state;180 }181 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);182 const offer = state.offers.find((o) => o.id === (action.offerId ?? state.offers[0].id)) ?? state.offers[0];183 const survived = rng.chance(offer.survival);184 const floorReached = state.stage + offer.advance;185 if (!survived) {186 state.status = "busted";187 state.current = 0;188 state.win = 0;189 state.log.push({ stage: floorReached, kind: offer.kind, label: bustLabel(offer.kind), detail: offer.label, multiplierAfter: 0, outcome: "bust" });190 state.offers = [];191 return state;192 }193 state.current = Math.min(def.maxMultiplier, stepMultiplier(state.current, offer.survival));194 state.stage = floorReached;195 const cp = cfg.checkpoints.includes(state.stage);196 if (cp) (state.extra.checkpointsHit as number[]).push(state.stage);197 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 } });198 if (state.stage >= cfg.floors) {199 state.status = "completed";200 state.win = Math.round(state.bet * state.current);201 state.log.push({ stage: state.stage, kind: "summit", label: "Floor 99 — the tower opens to the sky", multiplierAfter: r2(state.current), outcome: "cash" });202 state.offers = [];203 return state;204 }205 state.offers = escapeOffers(def, state, rng);206 return state;207}208209function bustLabel(kind: string): string {210 return (211 {212 enemy: "Caught by the guardian",213 trap: "Trap triggered",214 chest: "The chest was a mimic",215 portal: "Lost in the portal",216 "fork-safe": "The corridor collapsed",217 "fork-risky": "The corridor collapsed",218 multiplier: "The rune backfired",219 room: "The door locked behind you",220 }[kind] ?? "Run over"221 );222}223224/* ---------------------------------------------------------------- driver */225226export function ladderStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState {227 if (def.slug === "the-vault") return vaultStart(def, bet, rng);228 if (def.slug === "escape-99") return escapeStart(def, bet, rng);229 fail(`unknown ladder game ${def.slug}`);230}231232export function ladderAdvance(def: ArcadeGameDefinition, state: LadderState, rng: Rng, action: LadderAction): LadderState {233 if (def.slug === "the-vault") return vaultAdvance(def, state, rng, action);234 if (def.slug === "escape-99") return escapeAdvance(def, state, rng, action);235 fail(`unknown ladder game ${def.slug}`);236}237238/** Public projection: never leaks anything beyond what the player may see. */239export function ladderView(state: LadderState) {240 return {241 game: state.game,242 version: state.version,243 bet: state.bet,244 stage: state.stage,245 current: r2(state.current),246 status: state.status,247 win: state.win,248 offers: state.offers,249 log: state.log,250 extra: state.extra,251 canCashout: state.status === "running" && state.stage > 0,252 };253}254255/* ------------------------------------------------------------- simulation */256257/**258 * Mixed strategy population: most simulated players stop after a few stages259 * (geometric-like targets, mean ≈ 6 stages), 3% are "greedy" and pick any260 * target up to the top. RTP is strategy-independent by construction; the light261 * tail keeps the simulation variance small enough to certify in a few million rounds.262 */263export function simulateLadderRound(def: ArcadeGameDefinition, bet: number, rng: Rng): number {264 let state = ladderStart(def, bet, rng);265 const maxStage = def.slug === "the-vault" ? 5 : 99;266 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` stages267 const preferRisky = rng.chance(0.5);268 while (state.status === "running") {269 if (state.stage >= target) {270 state = ladderAdvance(def, state, rng, { type: "cashout" });271 break;272 }273 const offer = state.offers.length > 1 ? (preferRisky ? state.offers[1] : state.offers[0]) : state.offers[0];274 state = ladderAdvance(def, state, rng, { type: "continue", offerId: offer?.id });275 }276 return state.win;277}278