// Genius Square — pure game logic, no Phaser. // // Board is a 36-element flat array (6×6, row-major): // null = empty // 'blocked' = die/blocker occupying this cell // pieceId = placed piece id // // The 9 pieces total 29 cells; 7 blockers = 36 = 6×6. // ── Piece definitions ───────────────────────────────────────────────────────── export const PIECES = [ { id: 'dot', cells: [[0,0]] }, { id: 'domino', cells: [[0,0],[0,1]] }, { id: 'itromino', cells: [[0,0],[0,1],[0,2]] }, { id: 'ltromino', cells: [[0,0],[1,0],[1,1]] }, { id: 'square', cells: [[0,0],[0,1],[1,0],[1,1]] }, { id: 'ltetromino', cells: [[0,0],[1,0],[2,0],[2,1]] }, { id: 'stetromino', cells: [[0,1],[0,2],[1,0],[1,1]] }, { id: 'ttetromino', cells: [[0,0],[0,1],[0,2],[1,1]] }, { id: 'itetromino', cells: [[0,0],[0,1],[0,2],[0,3]] }, ]; export const PIECE_IDS = PIECES.map(p => p.id); // ── Orientation generation ──────────────────────────────────────────────────── // Enumerate all unique free-polyomino orientations (rotations + reflections) // using the same normalize/deduplicate approach as BlokusBoard.js. function normalize(cells) { let minR = Infinity, minC = Infinity; for (const [r, c] of cells) { if (r < minR) minR = r; if (c < minC) minC = c; } return cells.map(([r, c]) => [r - minR, c - minC]).sort((a, b) => a[0] - b[0] || a[1] - b[1]); } function keyOf(cells) { return cells.map(([r, c]) => `${r},${c}`).join(';'); } function computeOrientations(cells) { const seen = new Map(); const base = cells.map(([r, c]) => [r, c]); for (let flip = 0; flip < 2; flip++) { let work = flip ? base.map(([r, c]) => [r, -c]) : base; for (let rot = 0; rot < 4; rot++) { const norm = normalize(work); const k = keyOf(norm); if (!seen.has(k)) seen.set(k, norm); work = work.map(([r, c]) => [c, -r]); // rotate 90° clockwise } } return [...seen.values()]; } export const ORIENTATIONS = Object.fromEntries( PIECES.map(p => [p.id, computeOrientations(p.cells)]) ); const ORI_INDEX = Object.fromEntries( PIECES.map(p => [p.id, new Map(ORIENTATIONS[p.id].map((o, i) => [keyOf(o), i]))]) ); function transformedIndex(pieceId, oriIdx, fn) { const cur = ORIENTATIONS[pieceId][oriIdx]; const next = normalize(cur.map(fn)); return ORI_INDEX[pieceId].get(keyOf(next)) ?? oriIdx; } /** Orientation index after a 90° clockwise rotation. */ export function rotateOri(pieceId, oriIdx) { return transformedIndex(pieceId, oriIdx, ([r, c]) => [c, -r]); } /** Orientation index after a horizontal mirror. */ export function flipOri(pieceId, oriIdx) { return transformedIndex(pieceId, oriIdx, ([r, c]) => [r, -c]); } // ── Dice zones ──────────────────────────────────────────────────────────────── // 7 zones partition all 36 cells. One cell picked randomly from each zone // per round, ensuring blockers spread across the entire board. const ZONES = [ [[0,0],[0,1],[0,2],[0,3],[1,3]], [[0,4],[0,5],[1,4],[1,5],[2,5]], [[1,0],[1,1],[1,2],[2,0],[2,1]], [[2,2],[2,3],[2,4],[3,2],[3,3]], [[3,0],[3,1],[4,0],[4,1],[5,0]], [[3,4],[3,5],[4,4],[4,5],[5,5]], [[4,2],[4,3],[5,1],[5,2],[5,3],[5,4]], ]; /** Roll 7 dice → array of 7 [row, col] blocker positions. */ export function rollDice() { return ZONES.map(zone => zone[Math.floor(Math.random() * zone.length)]); } // ── Board helpers ───────────────────────────────────────────────────────────── export function newBoard(blockers) { const board = Array(36).fill(null); for (const [r, c] of blockers) board[r * 6 + c] = 'blocked'; return board; } /** Translate orientation-relative [dr,dc] cells to absolute [r,c] at an anchor. */ export function absoluteCells(oriCells, anchorR, anchorC) { return oriCells.map(([dr, dc]) => [anchorR + dr, anchorC + dc]); } export function canPlace(board, cells) { for (const [r, c] of cells) { if (r < 0 || r >= 6 || c < 0 || c >= 6) return false; if (board[r * 6 + c] !== null) return false; } return true; } export function placePiece(board, cells, pieceId) { const next = board.slice(); for (const [r, c] of cells) next[r * 6 + c] = pieceId; return next; } export function removePiece(board, cells) { const next = board.slice(); for (const [r, c] of cells) next[r * 6 + c] = null; return next; } export function isSolved(board) { return board.every(cell => cell !== null); } // ── Backtracking solver ─────────────────────────────────────────────────────── // Always targets the first null cell (left-to-right, top-to-bottom). For each // remaining piece, tries every orientation offset that covers that cell, then // recurses. This forces row-major filling and collapses the search space // dramatically — runs in <10ms for typical Genius Square boards. export function solve(board, remainingPieces) { const emptyIdx = board.indexOf(null); if (emptyIdx === -1) return remainingPieces.size === 0 ? [] : null; if (remainingPieces.size === 0) return null; const targetR = Math.floor(emptyIdx / 6); const targetC = emptyIdx % 6; for (const pieceId of remainingPieces) { const oris = ORIENTATIONS[pieceId]; for (let oriIdx = 0; oriIdx < oris.length; oriIdx++) { const oriCells = oris[oriIdx]; for (const [dr, dc] of oriCells) { const anchorR = targetR - dr; const anchorC = targetC - dc; const cells = absoluteCells(oriCells, anchorR, anchorC); if (!canPlace(board, cells)) continue; const nextBoard = placePiece(board, cells, pieceId); const nextRemaining = new Set(remainingPieces); nextRemaining.delete(pieceId); const sub = solve(nextBoard, nextRemaining); if (sub !== null) { return [{ pieceId, oriIdx, anchorR, anchorC, cells }, ...sub]; } } } } return null; } /** Convenience: build fresh board from blockers and solve with all 9 pieces. */ export function solveFromBlockers(blockers) { const board = newBoard(blockers); return solve(board, new Set(PIECE_IDS)); }