import type { Rng } from "../rng"; import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; /** * GRID//BREAK — an 8×8 grid of energy blocks. The player fires a wave down a * column; every cluster (≥3 connected blocks of one colour) the wave touches * detonates, blocks fall, new ones drop in, and any new cluster detonates in a * chain. Bombs clear 3×3, line blocks clear a row, ×2 blocks double the step. */ export interface GridbreakConfig { size: number; colors: number; /** Value per block by colour index, in bet units (× total bet / size²·k). */ values: number[]; weights: number[]; specialChance: { bomb: number; line: number; x2: number }; chainLadder: number[]; minCluster: number; maxChains: number; } export type Cell = { c: number; s?: "bomb" | "line" | "x2" }; export interface GridStep { grid: Cell[][]; // [col][row], row 0 = top destroyed: [number, number][]; specials: { type: string; at: [number, number] }[]; chain: number; multiplier: number; stepValue: number; win: number; } function drawCell(cfg: GridbreakConfig, rng: Rng): Cell { const c = rng.weighted(cfg.weights); const roll = rng.float(); if (roll < cfg.specialChance.bomb) return { c, s: "bomb" }; if (roll < cfg.specialChance.bomb + cfg.specialChance.line) return { c, s: "line" }; if (roll < cfg.specialChance.bomb + cfg.specialChance.line + cfg.specialChance.x2) return { c, s: "x2" }; return { c }; } function clusters(grid: Cell[][], size: number, minCluster: number): [number, number][][] { const seen = new Set(); const out: [number, number][][] = []; for (let x = 0; x < size; x++) for (let y = 0; y < size; y++) { const k = `${x}:${y}`; if (seen.has(k)) continue; const color = grid[x][y].c; const stack: [number, number][] = [[x, y]]; const group: [number, number][] = []; seen.add(k); while (stack.length) { const [cx, cy] = stack.pop()!; group.push([cx, cy]); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = cx + dx; const ny = cy + dy; if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue; const nk = `${nx}:${ny}`; if (seen.has(nk) || grid[nx][ny].c !== color) continue; seen.add(nk); stack.push([nx, ny]); } } if (group.length >= minCluster) out.push(group); } return out; } export function resolveGridbreak(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { column?: number }): ArcadeOutcome { const cfg = def.config as unknown as GridbreakConfig; const size = cfg.size; const column = Math.max(0, Math.min(size - 1, Math.round(inputRaw.column ?? Math.floor(size / 2)))); const grid: Cell[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => drawCell(cfg, rng))); const steps: GridStep[] = []; const features: string[] = []; let chain = 0; let totalUnits = 0; const unit = 1 / (size * size); // one block of value 1 ≈ 1/64 of the bet let firstWave = true; while (chain < cfg.maxChains) { const all = clusters(grid, size, cfg.minCluster); // The first wave only detonates clusters touching the fired column; chains detonate everything. const hit = firstWave ? all.filter((g) => g.some(([x]) => x === column)) : all; if (hit.length === 0) break; const destroyed = new Set(); const specials: GridStep["specials"] = []; let stepMult = 1; const push = (x: number, y: number) => { if (x >= 0 && y >= 0 && x < size && y < size) destroyed.add(`${x}:${y}`); }; for (const g of hit) for (const [x, y] of g) push(x, y); // Specials inside destroyed blocks trigger. for (const k of [...destroyed]) { const [x, y] = k.split(":").map(Number); const cell = grid[x][y]; if (cell.s === "bomb") { specials.push({ type: "bomb", at: [x, y] }); for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) push(x + dx, y + dy); features.push("Bomb"); } else if (cell.s === "line") { specials.push({ type: "line", at: [x, y] }); for (let xx = 0; xx < size; xx++) push(xx, y); features.push("Line clear"); } else if (cell.s === "x2") { specials.push({ type: "x2", at: [x, y] }); stepMult *= 2; features.push("×2 block"); } } const chainMult = cfg.chainLadder[Math.min(chain, cfg.chainLadder.length - 1)]; let value = 0; const destroyedList: [number, number][] = []; for (const k of destroyed) { const [x, y] = k.split(":").map(Number); value += cfg.values[grid[x][y].c] * unit; destroyedList.push([x, y]); } const stepUnits = value * stepMult * chainMult; totalUnits += stepUnits; steps.push({ grid: grid.map((c) => c.map((cell) => ({ ...cell }))), destroyed: destroyedList, specials, chain, multiplier: stepMult * chainMult, stepValue: Math.round(stepUnits * def.payScale * 10000) / 10000, win: Math.round(bet * stepUnits * def.payScale), }); // Gravity + refill. for (let x = 0; x < size; x++) { const kept: Cell[] = []; for (let y = 0; y < size; y++) if (!destroyed.has(`${x}:${y}`)) kept.push(grid[x][y]); const fresh: Cell[] = []; while (fresh.length + kept.length < size) fresh.push(drawCell(cfg, rng)); grid[x] = [...fresh, ...kept]; } chain++; firstWave = false; if (chain > 1) features.push("Chain reaction"); } // Final grid for presentation. steps.push({ grid: grid.map((c) => c.map((cell) => ({ ...cell }))), destroyed: [], specials: [], chain, multiplier: 1, stepValue: 0, win: 0 }); const scaled = totalUnits * 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: { column, chains: chain, blocksDestroyed: steps.reduce((a, s) => a + s.destroyed.length, 0) }, }; } export function randomGridbreakInput(def: ArcadeGameDefinition, rng: Rng): { column: number } { return { column: rng.int((def.config as unknown as GridbreakConfig).size) }; }