TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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