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%
5.8 KB · 144 lines typescript
Raw Blame History
1import type { Rng } from "../rng";2import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";34/**5 * DROPZONE — a capsule falls through a tower of pegs (binomial random walk),6 * passing gates (×2 boosts) and portals (lane swaps), into a row of buckets.7 * Deep Drop: the tower extends and the capsule keeps falling into a riskier8 * second bucket row whose values multiply the first (0× included).9 * The player picks a risk profile (bucket table) and a start lane.10 */1112export interface DropzoneConfig {13  rows: number;14  lanes: number; // = rows + 1 buckets15  /** Bucket multipliers per risk profile, symmetric, length = lanes. */16  buckets: Record<"low" | "medium" | "high", number[]>;17  gateChancePerRow: number;18  gateValues: number[];19  portalChancePerRow: number;20  deepDropChance: number;21  deepRows: number;22  deepBuckets: number[];23}2425export interface DropzoneInput {26  risk: "low" | "medium" | "high";27  /** Start lane 0..lanes-1 (the capsule is released above this bucket column). */28  lane: number;29}3031export interface DropStep {32  row: number;33  /** Horizontal position after this row (in half-lane units, 0 = far left). */34  x: number;35  event?: { type: "gate"; value: number } | { type: "portal"; to: number };36}3738/** Exact end-bucket distribution for a start lane (binomial walk in half-lane units with edge clamping). */39export function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] {40  const width = cfg.lanes * 2;41  let dist = new Array(width).fill(0);42  dist[startLane * 2 + 1] = 1;43  for (let row = 0; row < cfg.rows; row++) {44    const next = new Array(width).fill(0);45    for (let x = 0; x < width; x++) {46      if (!dist[x]) continue;47      const l = Math.max(0, x - 1);48      const r = Math.min(width - 1, x + 1);49      next[l] += dist[x] / 2;50      next[r] += dist[x] / 2;51    }52    dist = next;53  }54  const buckets = new Array(cfg.lanes).fill(0);55  for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x];56  return buckets;57}5859/** Per-lane normalisation so every start lane has the same expected base value (no "edge lane" exploit). */60export function laneNormalizer(cfg: DropzoneConfig, risk: DropzoneInput["risk"], startLane: number): number {61  const table = cfg.buckets[risk];62  const center = Math.floor(cfg.lanes / 2);63  const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0);64  return ev(center) / ev(startLane);65}6667/** Bucket values as the player will see them for a given lane/risk (already normalised and RTP-scaled). */68export function displayedBuckets(def: ArcadeGameDefinition, risk: DropzoneInput["risk"], startLane: number): number[] {69  const cfg = def.config as unknown as DropzoneConfig;70  const norm = laneNormalizer(cfg, risk, startLane);71  return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100);72}7374export function resolveDropzone(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: Partial<DropzoneInput>): ArcadeOutcome {75  const cfg = def.config as unknown as DropzoneConfig;76  const risk: DropzoneInput["risk"] = inputRaw.risk === "low" || inputRaw.risk === "high" ? inputRaw.risk : "medium";77  const lanes = cfg.lanes;78  const startLane = Math.max(0, Math.min(lanes - 1, Math.round(inputRaw.lane ?? Math.floor(lanes / 2))));79  const norm = laneNormalizer(cfg, risk, startLane);80  const table = cfg.buckets[risk].map((v) => v * norm);81  const features: string[] = [];82  const steps: DropStep[] = [];83  // Position in "half-lane" units: bucket k spans [2k, 2k+2). Start centred above the chosen lane.84  let x = startLane * 2 + 1;85  let gateMult = 1;86  for (let row = 0; row < cfg.rows; row++) {87    x += rng.chance(0.5) ? 1 : -1;88    x = Math.max(0, Math.min(lanes * 2 - 1, x));89    const step: DropStep = { row, x };90    if (rng.chance(cfg.gateChancePerRow)) {91      const v = cfg.gateValues[rng.int(cfg.gateValues.length)];92      gateMult *= v;93      step.event = { type: "gate", value: v };94      features.push("Gate");95    } else if (rng.chance(cfg.portalChancePerRow)) {96      const hop = (1 + rng.int(3)) * 2 * (rng.chance(0.5) ? 1 : -1);97      const to = Math.max(0, Math.min(lanes * 2 - 1, x + hop));98      step.event = { type: "portal", to };99      x = to;100      features.push("Portal");101    }102    steps.push(step);103  }104  const bucket = Math.min(lanes - 1, Math.floor(x / 2));105  const base = table[bucket] * gateMult;106  let deep: { steps: DropStep[]; bucket: number; value: number } | null = null;107  let total = base;108  if (rng.chance(cfg.deepDropChance)) {109    features.push("Deep Drop");110    const dsteps: DropStep[] = [];111    let dx = bucket * 2 + 1;112    for (let row = 0; row < cfg.deepRows; row++) {113      dx += rng.chance(0.5) ? 1 : -1;114      dx = Math.max(0, Math.min(cfg.deepBuckets.length * 2 - 1, dx));115      dsteps.push({ row, x: dx });116    }117    const db = Math.min(cfg.deepBuckets.length - 1, Math.floor(dx / 2));118    deep = { steps: dsteps, bucket: db, value: cfg.deepBuckets[db] };119    total = base * cfg.deepBuckets[db];120  }121  const scaled = total * def.payScale;122  const capped = scaled > def.maxMultiplier;123  const multiplier = Math.min(def.maxMultiplier, scaled);124  const totalWin = Math.round(bet * multiplier);125  return {126    game: def.slug,127    version: def.version,128    bet,129    totalWin,130    multiplier: bet ? totalWin / bet : 0,131    capped,132    features: [...new Set(features)],133    steps,134    summary: { risk, startLane, bucket, bucketValue: Math.round(table[bucket] * def.payScale * 100) / 100, gateMult, deep, table: table.map((v) => Math.round(v * def.payScale * 100) / 100) },135  };136}137138/** Random-input driver for simulation. */139export function randomDropzoneInput(def: ArcadeGameDefinition, rng: Rng): DropzoneInput {140  const cfg = def.config as unknown as DropzoneConfig;141  const risks: DropzoneInput["risk"][] = ["low", "medium", "high"];142  return { risk: risks[rng.int(3)], lane: rng.int(cfg.lanes) };143}144