fertig-classic-games/public/src/games/dotlink/DotLinkLogic.js

349 lines
13 KiB
JavaScript

// Dot Link — pure puzzle engine (no Phaser). Shared by the Phaser scene, the
// offline bank generator (server/scripts/genDotLink.js) and the verifier.
//
// "Dot Link" is a Flow-Free / Numberlink puzzle: every colour has two endpoint
// dots; connect each pair with a non-crossing path so that EVERY cell of the
// grid is covered exactly once.
//
// Board model:
// { rows, cols, endpoints: [ { color, a:[r,c], b:[r,c] }, ... ] }
// A solution assigns every cell to exactly one colour such that each colour
// forms a single simple path between its two endpoints and no cell is empty.
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
export function makeRng(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Hash an arbitrary string (e.g. a date "2026-06-14") to a uint32 seed so that
// everyone playing on the same day gets the same daily boards.
export function dateSeed(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
// Local YYYY-MM-DD for a Date (defaults to now).
export function localDateString(d = new Date()) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// ── Grid helpers ─────────────────────────────────────────────────────────────
function buildNeighbors(rows, cols) {
const nbr = new Array(rows * cols);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const list = [];
if (r > 0) list.push((r - 1) * cols + c);
if (r < rows - 1) list.push((r + 1) * cols + c);
if (c > 0) list.push(r * cols + (c - 1));
if (c < cols - 1) list.push(r * cols + (c + 1));
nbr[r * cols + c] = list;
}
}
return nbr;
}
export function manhattanAdjacent(a, b) {
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1;
}
// ── Solver / solution counter ────────────────────────────────────────────────
// Grows each colour's path from endpoint a toward endpoint b. Counts solutions
// up to `countLimit` (default 2 — enough to decide uniqueness). Aborts and
// reports `aborted:true` if it exceeds `maxNodes` search nodes.
//
// Returns { count, aborted, solvable }.
export function solve(board, opts = {}) {
const { rows, cols } = board;
const N = rows * cols;
const colors = board.endpoints.length;
const countLimit = opts.countLimit ?? 2;
const maxNodes = opts.maxNodes ?? 400000;
const nbr = buildNeighbors(rows, cols);
const idx = (rc) => rc[0] * cols + rc[1];
const grid = new Int16Array(N).fill(-1); // cell -> colour or -1 empty
const headAt = new Int16Array(N).fill(-1); // cell -> colour whose active head sits here
const goalAt = new Int16Array(N).fill(-1); // cell -> colour whose goal sits here (fixed)
const heads = new Int32Array(colors);
const goals = new Int32Array(colors);
const connected = new Uint8Array(colors);
for (let k = 0; k < colors; k++) {
const e = board.endpoints[k];
const a = idx(e.a);
const b = idx(e.b);
if (grid[a] !== -1 || grid[b] !== -1) {
return { count: 0, aborted: false, solvable: false }; // overlapping endpoints
}
grid[a] = k; grid[b] = k;
heads[k] = a; goals[k] = b;
headAt[a] = k;
goalAt[b] = k;
}
let count = 0;
let nodes = 0;
let aborted = false;
// Reusable BFS buffers (stamp-based to avoid per-call clears).
const reachStamp = new Int32Array(N); // empty cell reachable by some head
const seenStamp = new Int32Array(N);
const queue = new Int32Array(N);
let stamp = 0;
// Two safe, strong prunes (never reject a real solution):
// (1) Degree: every empty cell needs >=2 open connections (empty neighbour,
// active head, or open goal endpoint) since it must be an interior path
// cell of degree 2.
// (2) Reachability: each unconnected colour's head must still reach its goal
// through empty cells, AND every empty cell must be reachable from some
// head through empty cells (or it can never be filled).
function pruneOk() {
for (let i = 0; i < N; i++) {
if (grid[i] !== -1) continue;
let deg = 0;
const ns = nbr[i];
for (let j = 0; j < ns.length; j++) {
const nb = ns[j];
if (grid[nb] === -1) { deg++; if (deg >= 2) break; continue; }
const h = headAt[nb];
if (h !== -1 && !connected[h]) { deg++; if (deg >= 2) break; continue; }
const g = goalAt[nb];
if (g !== -1 && !connected[g]) { deg++; if (deg >= 2) break; }
}
if (deg < 2) return false;
}
const reachTag = ++stamp;
for (let k = 0; k < colors; k++) {
if (connected[k]) continue;
const head = heads[k];
const goal = goals[k];
const seenTag = ++stamp;
let qh = 0; let qt = 0;
let reachedGoal = false;
const hns = nbr[head];
for (let j = 0; j < hns.length; j++) {
const nb = hns[j];
if (nb === goal) { reachedGoal = true; }
else if (grid[nb] === -1 && seenStamp[nb] !== seenTag) {
seenStamp[nb] = seenTag; reachStamp[nb] = reachTag; queue[qt++] = nb;
}
}
while (qh < qt) {
const cur = queue[qh++];
const cns = nbr[cur];
for (let j = 0; j < cns.length; j++) {
const nb = cns[j];
if (nb === goal) { reachedGoal = true; continue; }
if (grid[nb] === -1 && seenStamp[nb] !== seenTag) {
seenStamp[nb] = seenTag; reachStamp[nb] = reachTag; queue[qt++] = nb;
}
}
}
if (!reachedGoal) return false;
}
for (let i = 0; i < N; i++) {
if (grid[i] === -1 && reachStamp[i] !== reachTag) return false;
}
return true;
}
function dfs() {
nodes++;
if (nodes > maxNodes) { aborted = true; return; }
// Most-constrained colour to extend (fewest moves). Bail if any unconnected
// colour has no move.
let allConnected = true;
let bestK = -1;
let bestMoves = null;
let bestLen = Infinity;
for (let k = 0; k < colors; k++) {
if (connected[k]) continue;
allConnected = false;
const h = heads[k];
const g = goals[k];
const moves = [];
const ns = nbr[h];
for (let j = 0; j < ns.length; j++) {
const nb = ns[j];
if (nb === g) moves.push(nb);
else if (grid[nb] === -1) moves.push(nb);
}
if (moves.length === 0) return; // stuck colour
if (moves.length < bestLen) { bestLen = moves.length; bestK = k; bestMoves = moves; }
}
if (allConnected) {
for (let i = 0; i < N; i++) if (grid[i] === -1) return; // unfilled -> dead
count++;
return;
}
if (!pruneOk()) return;
const k = bestK;
const h = heads[k];
const g = goals[k];
for (let m = 0; m < bestMoves.length; m++) {
const n = bestMoves[m];
if (n === g) {
// Connect: head reaches its goal.
headAt[h] = -1;
connected[k] = 1;
dfs();
connected[k] = 0;
headAt[h] = k;
} else {
grid[n] = k; headAt[h] = -1; headAt[n] = k; heads[k] = n;
dfs();
heads[k] = h; headAt[n] = -1; headAt[h] = k; grid[n] = -1;
}
if (aborted || count >= countLimit) return;
}
}
dfs();
return { count, aborted, solvable: count > 0 };
}
// ── Random board generation ──────────────────────────────────────────────────
// Generate a random Hamiltonian path over the whole grid via the "backbite"
// algorithm, then cut it into `colors` contiguous segments. Each segment's two
// ends become a colour's endpoints — guaranteeing a full-cover solution exists.
function randomHamiltonian(rows, cols, rng) {
const N = rows * cols;
const nbr = buildNeighbors(rows, cols);
const path = [];
for (let r = 0; r < rows; r++) {
if (r % 2 === 0) for (let c = 0; c < cols; c++) path.push(r * cols + c);
else for (let c = cols - 1; c >= 0; c--) path.push(r * cols + c);
}
const pos = new Int32Array(N);
for (let i = 0; i < N; i++) pos[path[i]] = i;
const reverse = (lo, hi) => {
while (lo < hi) {
const a = path[lo], b = path[hi];
path[lo] = b; path[hi] = a;
pos[b] = lo; pos[a] = hi;
lo++; hi--;
}
};
const iters = Math.max(2000, N * 30);
for (let it = 0; it < iters; it++) {
const L = path.length;
if (rng() < 0.5) {
// Backbite the tail.
const t = path[L - 1];
const cand = nbr[t];
const m = cand[Math.floor(rng() * cand.length)];
const k = pos[m];
if (k < L - 2) reverse(k + 1, L - 1);
} else {
// Backbite the head.
const hcell = path[0];
const cand = nbr[hcell];
const m = cand[Math.floor(rng() * cand.length)];
const k = pos[m];
if (k > 1) reverse(0, k - 1);
}
}
return path;
}
// Cut the Hamiltonian path into `colors` contiguous segments. Returns both the
// puzzle (endpoints) and the reference `solution` (each colour's full cell list)
// — the partition is itself a valid full-cover solution.
function cutIntoColors(ham, colors, rows, cols, rng) {
const N = ham.length;
if (colors > Math.floor(N / 2)) return null; // need >=2 cells per colour
const lens = new Array(colors).fill(2);
let rem = N - 2 * colors;
while (rem > 0) { lens[Math.floor(rng() * colors)]++; rem--; }
const endpoints = [];
const solution = [];
let p = 0;
for (let k = 0; k < colors; k++) {
const cells = [];
for (let j = 0; j < lens[k]; j++) {
const cell = ham[p + j];
cells.push([Math.floor(cell / cols), cell % cols]);
}
endpoints.push({ color: k, a: cells[0], b: cells[cells.length - 1] });
solution.push(cells);
p += lens[k];
}
return { board: { rows, cols, endpoints }, solution };
}
// Produce a board for the given size/colour count. Always returns a valid,
// solvable, full-cover board plus its reference solution. When `uniqueCheck` is
// set (cheap only on small grids) it prefers a board the search solver proves
// uniquely solvable, falling back to the first candidate otherwise.
// Deterministic for a given rng sequence. Returns { board, solution, unique }.
export function generateBoard(rows, cols, colors, rng, opts = {}) {
const cells = rows * cols;
const uniqueCheck = opts.uniqueCheck ?? (cells <= 64);
const tries = uniqueCheck ? (opts.tries ?? 24) : 1;
const maxNodes = opts.maxNodes ?? 200000;
let fallback = null;
for (let t = 0; t < tries; t++) {
const ham = randomHamiltonian(rows, cols, rng);
const cut = cutIntoColors(ham, colors, rows, cols, rng);
if (!cut) return null;
if (!fallback) fallback = cut;
if (!uniqueCheck) break;
const res = solve(cut.board, { countLimit: 2, maxNodes });
if (!res.aborted && res.count === 1) {
return { board: cut.board, solution: cut.solution, unique: true };
}
}
return { board: fallback.board, solution: fallback.solution, unique: false };
}
// ── Win check for live play ──────────────────────────────────────────────────
// `paths` maps colour index -> array of [r,c] cells the player has drawn for
// that colour (endpoints included). Returns true when every colour links its
// two endpoints with a contiguous simple path, every cell is covered exactly
// once, and nothing overlaps.
export function isSolved(board, paths) {
const { rows, cols } = board;
const owner = new Int16Array(rows * cols).fill(-1);
for (let k = 0; k < board.endpoints.length; k++) {
const path = paths[k];
if (!path || path.length < 2) return false;
const e = board.endpoints[k];
const first = path[0];
const last = path[path.length - 1];
const endsOk =
(first[0] === e.a[0] && first[1] === e.a[1] && last[0] === e.b[0] && last[1] === e.b[1]) ||
(first[0] === e.b[0] && first[1] === e.b[1] && last[0] === e.a[0] && last[1] === e.a[1]);
if (!endsOk) return false;
for (let i = 0; i < path.length; i++) {
const cell = path[i][0] * cols + path[i][1];
if (owner[cell] !== -1) return false; // overlap
owner[cell] = k;
if (i > 0 && !manhattanAdjacent(path[i - 1], path[i])) return false; // broken
}
}
for (let i = 0; i < owner.length; i++) if (owner[i] === -1) return false; // unfilled
return true;
}