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