// 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; }