318 lines
10 KiB
JavaScript
318 lines
10 KiB
JavaScript
// 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 };
|
||
}
|