// Tectonic (a.k.a. Suguru) generator. // // Rules: // • The grid is partitioned into irregular regions ("cages") of 1–5 cells. // • A region of N cells contains the numbers 1..N (so digits never exceed 5). // • No two cells that touch orthogonally OR diagonally (king-move / 8-way) // may hold the same number. // // generatePuzzle(difficulty) → { grid, solution, regions, difficulty } // grid : 8×8, 0 = empty cell the player must fill // solution : 8×8 fully-solved board // regions : 8×8 of integer region IDs (which cage each cell belongs to) // // Generation: // 1. Partition the grid into 1–5-cell regions (randomized greedy growth). // 2. Fill a random valid solution (backtracking on flat Int8Array boards with // precomputed peer lists and most-constrained-variable ordering). // 3. Dig holes: remove a cell only while the remaining givens are still // uniquely solvable *by logic alone* (naked singles + hidden singles in a // region). Logical solvability proves a unique solution AND guarantees the // puzzle is human-solvable without guessing. The propagation solver is // polynomial, so digging is fast and low-variance (unlike brute-force // solution-counting, which blows up on sparse Tectonic boards). const N = 8; const CELLS = N * N; const ORTHO = [[-1, 0], [1, 0], [0, -1], [0, 1]]; const KING = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1], ]; function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function inBounds(r, c) { return r >= 0 && r < N && c >= 0 && c < N; } function orthoNeighbors(r, c) { const out = []; for (const [dr, dc] of ORTHO) { const nr = r + dr, nc = c + dc; if (inBounds(nr, nc)) out.push([nr, nc]); } return out; } function bitToValue(bit) { return bit === 1 ? 1 : bit === 2 ? 2 : bit === 4 ? 3 : bit === 8 ? 4 : 5; } // ── Region partition ──────────────────────────────────────────────────────── // Greedy growth that seeds the most-constrained free cell (fewest free // neighbours) and grows toward size 5, always extending into the tightest // pocket. This keeps stranded size-1 regions rare without forbidding them. function partition() { const regions = Array.from({ length: N }, () => Array(N).fill(-1)); let unassigned = CELLS; let nextId = 0; const freeNeighborCount = (r, c) => { let cnt = 0; for (const [nr, nc] of orthoNeighbors(r, c)) if (regions[nr][nc] === -1) cnt++; return cnt; }; while (unassigned > 0) { const free = []; for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (regions[r][c] === -1) free.push([r, c]); shuffle(free); let seed = free[0], bestSeed = Infinity; for (const [r, c] of free) { const u = freeNeighborCount(r, c); if (u < bestSeed) { bestSeed = u; seed = [r, c]; } } const id = nextId++; const members = [seed]; regions[seed[0]][seed[1]] = id; while (members.length < 5) { const frontier = []; for (const [mr, mc] of members) for (const [nr, nc] of orthoNeighbors(mr, mc)) if (regions[nr][nc] === -1) frontier.push([nr, nc]); if (frontier.length === 0) break; shuffle(frontier); let pick = frontier[0], bestPick = Infinity; for (const [r, c] of frontier) { const u = freeNeighborCount(r, c); if (u < bestPick) { bestPick = u; pick = [r, c]; } } regions[pick[0]][pick[1]] = id; members.push(pick); } unassigned -= members.length; } return regions; } // Precompute flat lookup tables from a 2D region grid. // sizeOf[i] : size of i's region (max value allowed in i) // peers[i] : Int16Array of region-mates + king-neighbours (excl. self) // byRegion : Map regionId -> array of cell indices function buildTables(regions) { const regionOf = new Int16Array(CELLS); const byRegion = new Map(); for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { const i = r * N + c; const id = regions[r][c]; regionOf[i] = id; if (!byRegion.has(id)) byRegion.set(id, []); byRegion.get(id).push(i); } const sizeOf = new Int8Array(CELLS); const peers = new Array(CELLS); for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { const i = r * N + c; const fellows = byRegion.get(regionOf[i]); sizeOf[i] = fellows.length; const set = new Set(); for (const j of fellows) if (j !== i) set.add(j); for (const [dr, dc] of KING) { const nr = r + dr, nc = c + dc; if (inBounds(nr, nc)) set.add(nr * N + nc); } peers[i] = Int16Array.from(set); } return { sizeOf, peers, byRegion }; } // ── Solution fill (backtracking) ────────────────────────────────────────────── function valueOk(g, peers, i, v) { const p = peers[i]; for (let k = 0; k < p.length; k++) if (g[p[k]] === v) return false; return true; } // Most-constrained empty cell. Returns { idx, cands } or null when full; // short-circuits on the first forced (0- or 1-candidate) cell. function findBestCell(g, sizeOf, peers) { let bestIdx = -1, bestCands = null, bestLen = 99; for (let i = 0; i < CELLS; i++) { if (g[i] !== 0) continue; const max = sizeOf[i]; const cands = []; for (let v = 1; v <= max; v++) if (valueOk(g, peers, i, v)) cands.push(v); if (cands.length < bestLen) { bestLen = cands.length; bestIdx = i; bestCands = cands; if (bestLen <= 1) return { idx: bestIdx, cands: bestCands }; } } return bestIdx === -1 ? null : { idx: bestIdx, cands: bestCands }; } // Fills g in place with one random valid solution. Returns true on success, // false if unsatisfiable, or null if the node budget was exhausted. function solveOne(g, sizeOf, peers, budget) { let nodes = 0; function rec() { if (++nodes > budget) return null; const next = findBestCell(g, sizeOf, peers); if (!next) return true; if (next.cands.length === 0) return false; const { idx } = next; for (const v of shuffle([...next.cands])) { g[idx] = v; const r = rec(); if (r === true || r === null) return r; g[idx] = 0; } return false; } return rec(); } // ── Logic solver (naked + hidden singles) ───────────────────────────────────── // Returns true iff `given` is fully solvable by propagation alone — which proves // the solution is unique and the puzzle needs no guessing. function logicSolves(given, tables) { const { sizeOf, peers, byRegion } = tables; const val = new Int8Array(CELLS); const cand = new Int8Array(CELLS); for (let i = 0; i < CELLS; i++) cand[i] = (1 << sizeOf[i]) - 1; const assign = (i, v) => { val[i] = v; cand[i] = 1 << (v - 1); const p = peers[i]; const mask = ~(1 << (v - 1)); for (let k = 0; k < p.length; k++) if (val[p[k]] === 0) cand[p[k]] &= mask; }; for (let i = 0; i < CELLS; i++) if (given[i] !== 0) assign(i, given[i]); let progress = true; while (progress) { progress = false; // Naked singles for (let i = 0; i < CELLS; i++) { if (val[i] !== 0) continue; const c = cand[i]; if (c === 0) return false; // contradiction if ((c & (c - 1)) === 0) { // exactly one bit assign(i, bitToValue(c)); progress = true; } } if (progress) continue; // Hidden singles within each region for (const cells of byRegion.values()) { const size = cells.length; for (let v = 1; v <= size; v++) { const bit = 1 << (v - 1); let count = 0, where = -1, present = false; for (const i of cells) { if (val[i] === v) { present = true; break; } if (val[i] === 0 && (cand[i] & bit)) { count++; where = i; } } if (present) continue; if (count === 0) return false; // value has nowhere to go if (count === 1) { assign(where, v); progress = true; } } } } for (let i = 0; i < CELLS; i++) if (val[i] === 0) return false; return true; } // ── Hole digging (logic-guarded) ────────────────────────────────────────────── const GIVENS = { easy: 30, medium: 24, hard: 18 }; function dig(solution, tables, target) { const given = Int8Array.from(solution); const order = shuffle([...Array(CELLS).keys()]); let givens = CELLS; for (const i of order) { if (givens <= target) break; const saved = given[i]; given[i] = 0; if (logicSolves(given, tables)) givens--; else given[i] = saved; } return { given, givens }; } // ── Public API ─────────────────────────────────────────────────────────────── function buildSolved(deadline) { for (let attempt = 0; attempt < 200; attempt++) { if (Date.now() > deadline) return null; const regions = partition(); const tables = buildTables(regions); const sol = new Int8Array(CELLS); if (solveOne(sol, tables.sizeOf, tables.peers, 30000) === true) return { regions, tables, sol }; } return null; } function toGrid(flat) { const out = []; for (let r = 0; r < N; r++) { const row = []; for (let c = 0; c < N; c++) row.push(flat[r * N + c]); out.push(row); } return out; } export function generatePuzzle(difficulty) { const target = GIVENS[difficulty] ?? GIVENS.medium; const deadline = Date.now() + 3000; let best = null; while (Date.now() < deadline) { const built = buildSolved(deadline); if (!built) break; const { regions, tables, sol } = built; const { given, givens } = dig(sol, tables, target); if (givens <= target) { return { grid: toGrid(given), solution: toGrid(sol), regions, difficulty }; } if (!best || givens < best.givens) { best = { grid: toGrid(given), solution: toGrid(sol), regions, givens }; } } if (!best) throw new Error('tectonic: failed to generate a board'); return { grid: best.grid, solution: best.solution, regions: best.regions, difficulty }; }