fertig-classic-games/server/words/tectonicEngine.js

318 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Tectonic (a.k.a. Suguru) generator.
//
// Rules:
// • The grid is partitioned into irregular regions ("cages") of 15 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 15-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 };
}