import type { GameDefinition, Grid } from "../types"; import type { Rng } from "../rng"; import { drawSymbol, reelTables } from "../reels"; const key = (r: number, y: number) => `${r}:${y}`; /** Expanding wilds: every reel containing a wild becomes all-wild. Returns expanded reel indexes. */ export function applyExpandingWilds(def: GameDefinition, grid: Grid, free: boolean): number[] { const w = def.wild; if (!w?.expanding) return []; if (w.expanding === "freeSpins" && !free) return []; const out: number[] = []; for (let r = 0; r < grid.length; r++) { if (grid[r].includes(w.id) && !grid[r].every((s) => s === w.id)) { for (let y = 0; y < grid[r].length; y++) grid[r][y] = w.id; out.push(r); } else if (grid[r].includes(w.id) && grid[r].length > 1 && grid[r].every((s) => s === w.id)) { out.push(r); } } return out; } /** Floating / random wilds dropped before evaluation. Never overwrite scatters or existing wilds. */ export function applyFloatingWilds(def: GameDefinition, grid: Grid, rng: Rng, free: boolean, forced?: { min: number; max: number }): [number, number][] { const w = def.wild; if (!w) return []; let cfg = forced; if (!cfg) { if (!w.floating) return []; const chance = free && w.floating.freeSpinsChance !== undefined ? w.floating.freeSpinsChance : w.floating.chance; if (!rng.chance(chance)) return []; cfg = { min: w.floating.min, max: w.floating.max }; } const count = cfg.min + rng.int(cfg.max - cfg.min + 1); const cells: [number, number][] = []; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) { const id = grid[r][y]; if (id !== w.id && id !== def.scatter?.id && id !== def.holdRespin?.symbolId) cells.push([r, y]); } const added: [number, number][] = []; for (let i = 0; i < count && cells.length > 0; i++) { const idx = rng.int(cells.length); const [r, y] = cells.splice(idx, 1)[0]; grid[r][y] = w.id; added.push([r, y]); } return added; } /** Assign multipliers to wilds present on the grid (only cells not yet assigned). */ export function assignWildMultipliers( def: GameDefinition, grid: Grid, rng: Rng, free: boolean, existing: Map, ): { position: [number, number]; value: number }[] { const w = def.wild; if (!w?.multiplier) return []; if (w.multiplier.freeSpinsOnly && !free) return []; const out: { position: [number, number]; value: number }[] = []; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) { if (grid[r][y] !== w.id) continue; const k = key(r, y); if (existing.has(k)) continue; const v = w.multiplier.values[rng.weighted(w.multiplier.weights)]; existing.set(k, v); out.push({ position: [r, y], value: v }); } return out; } /** Exploding wilds: the 8 neighbours of each *new* wild that are regular symbols are destroyed. */ export function explodeWilds(def: GameDefinition, grid: Grid, newWilds: [number, number][]): [number, number][] { const w = def.wild; if (!w?.exploding || newWilds.length === 0) return []; const tables = reelTables(def); const removed = new Set(); const out: [number, number][] = []; for (const [r, y] of newWilds) { for (let dr = -1; dr <= 1; dr++) for (let dy = -1; dy <= 1; dy++) { if (dr === 0 && dy === 0) continue; const rr = r + dr; const yy = y + dy; if (rr < 0 || rr >= grid.length || yy < 0 || yy >= grid[rr].length) continue; const id = grid[rr][yy]; const sym = tables.byId.get(id); if (sym?.kind === "regular" && !removed.has(key(rr, yy))) { removed.add(key(rr, yy)); out.push([rr, yy]); } } } return out; } /** Positions of wilds on the grid. */ export function wildPositions(def: GameDefinition, grid: Grid): [number, number][] { const w = def.wild?.id; const out: [number, number][] = []; if (!w) return out; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) if (grid[r][y] === w) out.push([r, y]); return out; } /** Moving wilds: shift every tracked wild one reel to the left; drop the ones that leave the grid. */ export function shiftMovingWilds(positions: [number, number][]): [number, number][] { return positions.filter(([r]) => r - 1 >= 0).map(([r, y]) => [r - 1, y] as [number, number]); } /** Overwrite grid cells with wilds (sticky / moving). */ export function stampWilds(def: GameDefinition, grid: Grid, positions: [number, number][]): void { const w = def.wild?.id; if (!w) return; for (const [r, y] of positions) if (r < grid.length && y < grid[r].length) grid[r][y] = w; } /** Upgrade one low-tier symbol type present on the grid to a random mid/high symbol ("upgradeLow" feature). */ export function upgradeLowSymbols(def: GameDefinition, grid: Grid, rng: Rng): number { const tables = reelTables(def); const targets = def.symbols.filter((s) => s.kind === "regular" && (s.tier === "mid" || s.tier === "high")).map((s) => s.id); if (targets.length === 0) return 0; const lowsPresent = [...new Set(grid.flat().filter((id) => tables.byId.get(id)?.tier === "low"))]; if (lowsPresent.length === 0) return 0; const from = lowsPresent[rng.int(lowsPresent.length)]; const pick = targets[rng.int(targets.length)]; let n = 0; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) { if (grid[r][y] === from) { grid[r][y] = pick; n++; } } return n; } /** Refill a reel column after removals by dropping surviving symbols down and drawing new ones on top. */ export function refillGrid(def: GameDefinition, grid: Grid, removed: [number, number][], rng: Rng, free: boolean, protectedCells?: Set): void { const tables = reelTables(def); const removedSet = new Set(removed.filter(([r, y]) => !protectedCells?.has(key(r, y))).map(([r, y]) => key(r, y))); for (let r = 0; r < grid.length; r++) { const col = grid[r]; const rows = col.length; const kept: string[] = []; // Row 0 is the top. Survivors fall to the bottom, keeping order. for (let y = 0; y < rows; y++) if (!removedSet.has(key(r, y))) kept.push(col[y]); const missing = rows - kept.length; if (missing === 0) continue; const fresh: string[] = []; for (let i = 0; i < missing; i++) fresh.push(drawSymbol(tables, r, free, rng)); const next = [...fresh, ...kept]; for (let y = 0; y < rows; y++) col[y] = next[y]; } }