349 lines
13 KiB
JavaScript
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;
|
|
}
|