TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { CoinCell, GameDefinition, Grid, PickBonusConfig } from "../types";2import type { Rng } from "../rng";3import { reelTables } from "../reels";4import { credits } from "../evaluate";56/** Mystery symbols: all reveal into the same random symbol. */7export function revealMystery(def: GameDefinition, grid: Grid, rng: Rng, forcedPool?: string[]): { symbol: string; positions: [number, number][] } | null {8 const m = def.mystery;9 if (!m) return null;10 const positions: [number, number][] = [];11 for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) if (grid[r][y] === m.symbolId) positions.push([r, y]);12 if (positions.length === 0) return null;13 const tables = reelTables(def);14 const pool = forcedPool ?? m.revealPool ?? tables.regularIds;15 const weights = forcedPool ? pool.map(() => 1) : (m.revealWeights ?? pool.map(() => 1));16 const symbol = pool[rng.weighted(weights)];17 for (const [r, y] of positions) grid[r][y] = symbol;18 return { symbol, positions };19}2021/** Add extra mystery symbols to the grid (mysteryBoost feature). */22export function boostMystery(def: GameDefinition, grid: Grid, rng: Rng, count: number): number {23 const m = def.mystery;24 if (!m) return 0;25 const tables = reelTables(def);26 const cells: [number, number][] = [];27 for (let r = 0; r < grid.length; r++)28 for (let y = 0; y < grid[r].length; y++) if (tables.byId.get(grid[r][y])?.kind === "regular") cells.push([r, y]);29 let n = 0;30 for (let i = 0; i < count && cells.length > 0; i++) {31 const [r, y] = cells.splice(rng.int(cells.length), 1)[0];32 grid[r][y] = m.symbolId;33 n++;34 }35 return n;36}3738/** Quantum split: some regular cells gain multiplicity, multiplying ways. */39export function quantumSplit(def: GameDefinition, grid: Grid, rng: Rng): { multiplicity: number[][]; splits: { position: [number, number]; multiplicity: number }[] } | null {40 const q = def.quantum;41 if (!q || !rng.chance(q.chance)) return null;42 const tables = reelTables(def);43 const cells: [number, number][] = [];44 for (let r = 0; r < grid.length; r++)45 for (let y = 0; y < grid[r].length; y++) {46 const k = tables.byId.get(grid[r][y])?.kind;47 if (k === "regular" || k === "wild") cells.push([r, y]);48 }49 const n = Math.min(cells.length, q.cells.min + rng.int(q.cells.max - q.cells.min + 1));50 const multiplicity = grid.map((c) => c.map(() => 1));51 const splits: { position: [number, number]; multiplicity: number }[] = [];52 for (let i = 0; i < n; i++) {53 const [r, y] = cells.splice(rng.int(cells.length), 1)[0];54 const m = q.splits.values[rng.weighted(q.splits.weights)];55 multiplicity[r][y] = m;56 splits.push({ position: [r, y], multiplicity: m });57 }58 return { multiplicity, splits };59}6061/** Resolve a pick bonus. Prizes are multiples of bet. */62export function resolvePickBonus(def: GameDefinition, cfg: PickBonusConfig, bet: number, rng: Rng): { picks: { cell: number; value: number; amount: number }[]; total: number } {63 const cells = Array.from({ length: cfg.cells }, (_, i) => i);64 const picks: { cell: number; value: number; amount: number }[] = [];65 let total = 0;66 for (let i = 0; i < cfg.picks && cells.length > 0; i++) {67 const cell = cells.splice(rng.int(cells.length), 1)[0];68 const value = cfg.prizes.values[rng.weighted(cfg.prizes.weights)];69 const amount = Math.round(credits(bet, value, def.payScale));70 picks.push({ cell, value, amount });71 total += amount;72 if (cfg.endOn !== undefined && value === cfg.endOn) break;73 }74 return { picks, total };75}7677export interface HoldRespinResult {78 steps: { coins: CoinCell[]; respinsLeft: number; grid: Grid }[];79 total: number;80 jackpot: { tier: string; amount: number } | null;81 fullGrid: boolean;82}8384function drawCoin(def: GameDefinition, bet: number, rng: Rng, reel: number, row: number): CoinCell {85 const cfg = def.holdRespin!;86 if (cfg.jackpotTagChance) {87 const t = cfg.jackpotTagChance;88 const roll = rng.float();89 let acc = 0;90 for (const tier of ["grand", "major", "minor", "mini"] as const) {91 const p = t[tier] ?? 0;92 acc += p;93 if (roll < acc) return { reel, row, value: null, jackpot: tier, isNew: true };94 }95 }96 const v = cfg.values.values[rng.weighted(cfg.values.weights)];97 return { reel, row, value: Math.round(credits(bet, v, def.payScale)), isNew: true };98}99100/** Lock-and-respin feature, fully resolved. */101export function resolveHoldRespin(def: GameDefinition, grid: Grid, bet: number, rng: Rng): HoldRespinResult | null {102 const cfg = def.holdRespin;103 if (!cfg) return null;104 const reels = grid.length;105 const rows = grid[0].length;106 const coins: CoinCell[] = [];107 for (let r = 0; r < reels; r++) for (let y = 0; y < rows; y++) if (grid[r][y] === cfg.symbolId) coins.push(drawCoin(def, bet, rng, r, y));108 if (coins.length < cfg.minToTrigger) return null;109110 const steps: HoldRespinResult["steps"] = [];111 const blank = "__";112 const makeGrid = (): Grid => {113 const g: Grid = Array.from({ length: reels }, () => new Array(rows).fill(blank));114 for (const c of coins) g[c.reel][c.row] = cfg.symbolId;115 return g;116 };117 let respins = cfg.respins;118 steps.push({ coins: coins.map((c) => ({ ...c })), respinsLeft: respins, grid: makeGrid() });119 for (const c of coins) c.isNew = false;120 const capacity = reels * rows;121 let guard = 0;122 while (respins > 0 && coins.length < capacity && guard++ < 200) {123 let landed = false;124 const occupied = new Set(coins.map((c) => `${c.reel}:${c.row}`));125 for (let r = 0; r < reels; r++)126 for (let y = 0; y < rows; y++) {127 if (occupied.has(`${r}:${y}`)) continue;128 if (rng.chance(cfg.landChance)) {129 coins.push(drawCoin(def, bet, rng, r, y));130 landed = true;131 }132 }133 respins = landed ? cfg.respins : respins - 1;134 steps.push({ coins: coins.map((c) => ({ ...c })), respinsLeft: respins, grid: makeGrid() });135 for (const c of coins) c.isNew = false;136 }137 let total = 0;138 let best: { tier: string; amount: number } | null = null;139 const fullGrid = coins.length >= capacity;140 for (const c of coins) {141 if (c.value !== null) total += c.value;142 else if (c.jackpot) {143 const amt = Math.round(cfg.jackpots[c.jackpot] * bet);144 total += amt;145 if (!best || amt > best.amount) best = { tier: c.jackpot, amount: amt };146 }147 }148 if (fullGrid && cfg.fullGridJackpot) {149 const amt = Math.round(cfg.jackpots[cfg.fullGridJackpot] * bet);150 total += amt;151 best = { tier: cfg.fullGridJackpot, amount: amt };152 }153 return { steps, total, jackpot: best, fullGrid };154}155