import type { CoinCell, GameDefinition, Grid, PickBonusConfig } from "../types"; import type { Rng } from "../rng"; import { reelTables } from "../reels"; import { credits } from "../evaluate"; /** Mystery symbols: all reveal into the same random symbol. */ export function revealMystery(def: GameDefinition, grid: Grid, rng: Rng, forcedPool?: string[]): { symbol: string; positions: [number, number][] } | null { const m = def.mystery; if (!m) return null; const positions: [number, number][] = []; 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]); if (positions.length === 0) return null; const tables = reelTables(def); const pool = forcedPool ?? m.revealPool ?? tables.regularIds; const weights = forcedPool ? pool.map(() => 1) : (m.revealWeights ?? pool.map(() => 1)); const symbol = pool[rng.weighted(weights)]; for (const [r, y] of positions) grid[r][y] = symbol; return { symbol, positions }; } /** Add extra mystery symbols to the grid (mysteryBoost feature). */ export function boostMystery(def: GameDefinition, grid: Grid, rng: Rng, count: number): number { const m = def.mystery; if (!m) return 0; const tables = reelTables(def); const cells: [number, number][] = []; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) if (tables.byId.get(grid[r][y])?.kind === "regular") cells.push([r, y]); let n = 0; for (let i = 0; i < count && cells.length > 0; i++) { const [r, y] = cells.splice(rng.int(cells.length), 1)[0]; grid[r][y] = m.symbolId; n++; } return n; } /** Quantum split: some regular cells gain multiplicity, multiplying ways. */ export function quantumSplit(def: GameDefinition, grid: Grid, rng: Rng): { multiplicity: number[][]; splits: { position: [number, number]; multiplicity: number }[] } | null { const q = def.quantum; if (!q || !rng.chance(q.chance)) return null; const tables = reelTables(def); const cells: [number, number][] = []; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) { const k = tables.byId.get(grid[r][y])?.kind; if (k === "regular" || k === "wild") cells.push([r, y]); } const n = Math.min(cells.length, q.cells.min + rng.int(q.cells.max - q.cells.min + 1)); const multiplicity = grid.map((c) => c.map(() => 1)); const splits: { position: [number, number]; multiplicity: number }[] = []; for (let i = 0; i < n; i++) { const [r, y] = cells.splice(rng.int(cells.length), 1)[0]; const m = q.splits.values[rng.weighted(q.splits.weights)]; multiplicity[r][y] = m; splits.push({ position: [r, y], multiplicity: m }); } return { multiplicity, splits }; } /** Resolve a pick bonus. Prizes are multiples of bet. */ export function resolvePickBonus(def: GameDefinition, cfg: PickBonusConfig, bet: number, rng: Rng): { picks: { cell: number; value: number; amount: number }[]; total: number } { const cells = Array.from({ length: cfg.cells }, (_, i) => i); const picks: { cell: number; value: number; amount: number }[] = []; let total = 0; for (let i = 0; i < cfg.picks && cells.length > 0; i++) { const cell = cells.splice(rng.int(cells.length), 1)[0]; const value = cfg.prizes.values[rng.weighted(cfg.prizes.weights)]; const amount = Math.round(credits(bet, value, def.payScale)); picks.push({ cell, value, amount }); total += amount; if (cfg.endOn !== undefined && value === cfg.endOn) break; } return { picks, total }; } export interface HoldRespinResult { steps: { coins: CoinCell[]; respinsLeft: number; grid: Grid }[]; total: number; jackpot: { tier: string; amount: number } | null; fullGrid: boolean; } function drawCoin(def: GameDefinition, bet: number, rng: Rng, reel: number, row: number): CoinCell { const cfg = def.holdRespin!; if (cfg.jackpotTagChance) { const t = cfg.jackpotTagChance; const roll = rng.float(); let acc = 0; for (const tier of ["grand", "major", "minor", "mini"] as const) { const p = t[tier] ?? 0; acc += p; if (roll < acc) return { reel, row, value: null, jackpot: tier, isNew: true }; } } const v = cfg.values.values[rng.weighted(cfg.values.weights)]; return { reel, row, value: Math.round(credits(bet, v, def.payScale)), isNew: true }; } /** Lock-and-respin feature, fully resolved. */ export function resolveHoldRespin(def: GameDefinition, grid: Grid, bet: number, rng: Rng): HoldRespinResult | null { const cfg = def.holdRespin; if (!cfg) return null; const reels = grid.length; const rows = grid[0].length; const coins: CoinCell[] = []; 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)); if (coins.length < cfg.minToTrigger) return null; const steps: HoldRespinResult["steps"] = []; const blank = "__"; const makeGrid = (): Grid => { const g: Grid = Array.from({ length: reels }, () => new Array(rows).fill(blank)); for (const c of coins) g[c.reel][c.row] = cfg.symbolId; return g; }; let respins = cfg.respins; steps.push({ coins: coins.map((c) => ({ ...c })), respinsLeft: respins, grid: makeGrid() }); for (const c of coins) c.isNew = false; const capacity = reels * rows; let guard = 0; while (respins > 0 && coins.length < capacity && guard++ < 200) { let landed = false; const occupied = new Set(coins.map((c) => `${c.reel}:${c.row}`)); for (let r = 0; r < reels; r++) for (let y = 0; y < rows; y++) { if (occupied.has(`${r}:${y}`)) continue; if (rng.chance(cfg.landChance)) { coins.push(drawCoin(def, bet, rng, r, y)); landed = true; } } respins = landed ? cfg.respins : respins - 1; steps.push({ coins: coins.map((c) => ({ ...c })), respinsLeft: respins, grid: makeGrid() }); for (const c of coins) c.isNew = false; } let total = 0; let best: { tier: string; amount: number } | null = null; const fullGrid = coins.length >= capacity; for (const c of coins) { if (c.value !== null) total += c.value; else if (c.jackpot) { const amt = Math.round(cfg.jackpots[c.jackpot] * bet); total += amt; if (!best || amt > best.amount) best = { tier: c.jackpot, amount: amt }; } } if (fullGrid && cfg.fullGridJackpot) { const amt = Math.round(cfg.jackpots[cfg.fullGridJackpot] * bet); total += amt; best = { tier: cfg.fullGridJackpot, amount: amt }; } return { steps, total, jackpot: best, fullGrid }; }