import type { Rng } from "../rng"; import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; /** * DROPZONE — a capsule falls through a tower of pegs (binomial random walk), * passing gates (×2 boosts) and portals (lane swaps), into a row of buckets. * Deep Drop: the tower extends and the capsule keeps falling into a riskier * second bucket row whose values multiply the first (0× included). * The player picks a risk profile (bucket table) and a start lane. */ export interface DropzoneConfig { rows: number; lanes: number; // = rows + 1 buckets /** Bucket multipliers per risk profile, symmetric, length = lanes. */ buckets: Record<"low" | "medium" | "high", number[]>; gateChancePerRow: number; gateValues: number[]; portalChancePerRow: number; deepDropChance: number; deepRows: number; deepBuckets: number[]; } export interface DropzoneInput { risk: "low" | "medium" | "high"; /** Start lane 0..lanes-1 (the capsule is released above this bucket column). */ lane: number; } export interface DropStep { row: number; /** Horizontal position after this row (in half-lane units, 0 = far left). */ x: number; event?: { type: "gate"; value: number } | { type: "portal"; to: number }; } /** Exact end-bucket distribution for a start lane (binomial walk in half-lane units with edge clamping). */ export function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] { const width = cfg.lanes * 2; let dist = new Array(width).fill(0); dist[startLane * 2 + 1] = 1; for (let row = 0; row < cfg.rows; row++) { const next = new Array(width).fill(0); for (let x = 0; x < width; x++) { if (!dist[x]) continue; const l = Math.max(0, x - 1); const r = Math.min(width - 1, x + 1); next[l] += dist[x] / 2; next[r] += dist[x] / 2; } dist = next; } const buckets = new Array(cfg.lanes).fill(0); for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x]; return buckets; } /** Per-lane normalisation so every start lane has the same expected base value (no "edge lane" exploit). */ export function laneNormalizer(cfg: DropzoneConfig, risk: DropzoneInput["risk"], startLane: number): number { const table = cfg.buckets[risk]; const center = Math.floor(cfg.lanes / 2); const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0); return ev(center) / ev(startLane); } /** Bucket values as the player will see them for a given lane/risk (already normalised and RTP-scaled). */ export function displayedBuckets(def: ArcadeGameDefinition, risk: DropzoneInput["risk"], startLane: number): number[] { const cfg = def.config as unknown as DropzoneConfig; const norm = laneNormalizer(cfg, risk, startLane); return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100); } export function resolveDropzone(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: Partial): ArcadeOutcome { const cfg = def.config as unknown as DropzoneConfig; const risk: DropzoneInput["risk"] = inputRaw.risk === "low" || inputRaw.risk === "high" ? inputRaw.risk : "medium"; const lanes = cfg.lanes; const startLane = Math.max(0, Math.min(lanes - 1, Math.round(inputRaw.lane ?? Math.floor(lanes / 2)))); const norm = laneNormalizer(cfg, risk, startLane); const table = cfg.buckets[risk].map((v) => v * norm); const features: string[] = []; const steps: DropStep[] = []; // Position in "half-lane" units: bucket k spans [2k, 2k+2). Start centred above the chosen lane. let x = startLane * 2 + 1; let gateMult = 1; for (let row = 0; row < cfg.rows; row++) { x += rng.chance(0.5) ? 1 : -1; x = Math.max(0, Math.min(lanes * 2 - 1, x)); const step: DropStep = { row, x }; if (rng.chance(cfg.gateChancePerRow)) { const v = cfg.gateValues[rng.int(cfg.gateValues.length)]; gateMult *= v; step.event = { type: "gate", value: v }; features.push("Gate"); } else if (rng.chance(cfg.portalChancePerRow)) { const hop = (1 + rng.int(3)) * 2 * (rng.chance(0.5) ? 1 : -1); const to = Math.max(0, Math.min(lanes * 2 - 1, x + hop)); step.event = { type: "portal", to }; x = to; features.push("Portal"); } steps.push(step); } const bucket = Math.min(lanes - 1, Math.floor(x / 2)); const base = table[bucket] * gateMult; let deep: { steps: DropStep[]; bucket: number; value: number } | null = null; let total = base; if (rng.chance(cfg.deepDropChance)) { features.push("Deep Drop"); const dsteps: DropStep[] = []; let dx = bucket * 2 + 1; for (let row = 0; row < cfg.deepRows; row++) { dx += rng.chance(0.5) ? 1 : -1; dx = Math.max(0, Math.min(cfg.deepBuckets.length * 2 - 1, dx)); dsteps.push({ row, x: dx }); } const db = Math.min(cfg.deepBuckets.length - 1, Math.floor(dx / 2)); deep = { steps: dsteps, bucket: db, value: cfg.deepBuckets[db] }; total = base * cfg.deepBuckets[db]; } const scaled = total * def.payScale; const capped = scaled > def.maxMultiplier; const multiplier = Math.min(def.maxMultiplier, scaled); const totalWin = Math.round(bet * multiplier); return { game: def.slug, version: def.version, bet, totalWin, multiplier: bet ? totalWin / bet : 0, capped, features: [...new Set(features)], steps, 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) }, }; } /** Random-input driver for simulation. */ export function randomDropzoneInput(def: ArcadeGameDefinition, rng: Rng): DropzoneInput { const cfg = def.config as unknown as DropzoneConfig; const risks: DropzoneInput["risk"][] = ["low", "medium", "high"]; return { risk: risks[rng.int(3)], lane: rng.int(cfg.lanes) }; }