// Pipe Puzzle — pure game logic (no Phaser, runs in Node for verification). // // Strict "all-tiles-connected" variant: // • The board is an N×N grid, **every cell holds a pipe tile**. // • Exactly two special 1-socket tiles: a SOURCE (faucet) and a DRAIN. // • Every other tile is a 2-socket STRAIGHT or ELBOW. // • The solved state is a single continuous, leak-free pipe path that runs // from the faucet to the drain and visits every cell exactly once — a // Hamiltonian path. So "every tile is connected" and "no leaks" fall out // of one clean condition. // • The puzzle is generated by building a random Hamiltonian path, orienting // every tile along it (the solution), then randomly rotating the tiles. // It is therefore always solvable. // // Directions / sockets // Bit flags per socket: N=1, E=2, S=4, W=8. A tile's `sockets` value is the // OR of the sockets it currently has. `rotateSockets` turns it clockwise. // ── Directions ─────────────────────────────────────────────────────────────── export const N = 1, E = 2, S = 4, W = 8; export const DIRS = [N, E, S, W]; export const OPP = { [N]: S, [S]: N, [E]: W, [W]: E }; export const DELTA = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }; // Tile kinds (used by the renderer for art; the socket mask is the source of // truth for connectivity). export const TILE = { SOURCE: 'source', DRAIN: 'drain', STRAIGHT: 'straight', ELBOW: 'elbow' }; // ── Random helpers ─────────────────────────────────────────────────────────── export function randInt(maxExclusive) { return Math.floor(Math.random() * maxExclusive); } export function shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = randInt(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; } // ── Grid helpers ───────────────────────────────────────────────────────────── export function cellRC(i, n) { const r = Math.floor(i / n), c = i % n; return [r, c]; } export function neighborOf(i, n, d) { const [r, c] = cellRC(i, n); const [dr, dc] = DELTA[d]; return (r + dr) * n + (c + dc); } // Directions of i whose sockets are matched by the neighbor (water can flow there). export function matchedDirs(sockets, n, i) { const [r, c] = cellRC(i, n); const out = []; for (const d of DIRS) { if (!(sockets[i] & d)) continue; const [dr, dc] = DELTA[d]; const nr = r + dr, nc = c + dc; if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue; if (sockets[nr * n + nc] & OPP[d]) out.push(d); } return out; } export const cellKey = (r, c, n) => r * n + c; export function gridAdjacent(a, b, n) { const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n); return Math.abs(ar - br) + Math.abs(ac - bc) === 1; } // Direction bit from cell a toward adjacent cell b. function dirBetween(a, b, n) { const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n); if (br === ar - 1) return N; if (br === ar + 1) return S; if (bc === ac - 1) return W; return E; } // ── Socket algebra ─────────────────────────────────────────────────────────── // Rotate a socket mask `rot` steps clockwise (N→E→S→W→N). export function rotateSockets(sock, rot) { rot = ((rot % 4) + 4) % 4; for (let i = 0; i < rot; i++) { let out = 0; if (sock & N) out |= E; if (sock & E) out |= S; if (sock & S) out |= W; if (sock & W) out |= N; sock = out; } return sock; } // ── Hamiltonian path generation ────────────────────────────────────────────── // A guaranteed-valid snake (boustrophedon) path covering every cell. function snakePath(n) { const horizontal = Math.random() < 0.5; const path = []; if (horizontal) { for (let r = 0; r < n; r++) { for (let c = 0; c < n; c++) path.push(cellKey(r, (r % 2 === 0) ? c : n - 1 - c, n)); } } else { for (let c = 0; c < n; c++) { for (let r = 0; r < n; r++) path.push(cellKey((c % 2 === 0) ? r : n - 1 - r, c, n)); } } if (Math.random() < 0.5) path.reverse(); return path; } // Randomize a Hamiltonian path with "2-switch" (detour) moves. // // Pick two path edges (a→b) and (c→d) with a non-trivial segment between // them; if a~c and b~d are both valid grid adjacencies, reroute to // a→c … d→b by reversing the middle segment. This is the standard // Hamiltonian-path improvement move: it keeps the path a permutation of all // cells (nothing is duplicated or dropped) and preserves every adjacency, // so the result is always a valid Hamiltonian path — the puzzle stays // solvable by construction. // // On a snake this produces detours that weave between rows/columns, giving // each puzzle a distinct shape and distinct source/drain cells. function randomizePath(path, n, attempts = 600) { const len = path.length; for (let t = 0; t < attempts; t++) { let p = randInt(len - 1); let q = randInt(len - 1); if (p > q) [p, q] = [q, p]; if (q - p < 1) continue; // need at least one cell between the edges const a = path[p], b = path[p + 1], c = path[q], d = path[q + 1]; if (gridAdjacent(a, c, n) && gridAdjacent(b, d, n)) { const left = path.slice(0, p + 1); // … a const mid = path.slice(p + 1, q + 1); // b … c const right = path.slice(q + 1); // d … path = left.concat(mid.reverse(), right); } } if (Math.random() < 0.5) path.reverse(); return path; } export function randomHamiltonianPath(n) { return randomizePath(snakePath(n), n); } // ── Puzzle construction ────────────────────────────────────────────────────── // Orient every tile along the path (the solution), then scramble. export function generatePuzzle(n) { const path = randomHamiltonianPath(n); const total = path.length; // Solution: sockets per cell, following the path. const solution = new Array(total).fill(0); for (let i = 0; i < total; i++) { let sock = 0; if (i > 0) sock |= dirBetween(path[i], path[i - 1], n); if (i < total - 1) sock |= dirBetween(path[i], path[i + 1], n); solution[path[i]] = sock; } const source = path[0]; const drain = path[total - 1]; // Scramble: random rotation of every tile (source & drain included). const sockets = solution.map((s) => (s === 0 ? 0 : rotateSockets(s, randInt(4)))); // If the scramble happened to produce the solved board (only possible for a // 1-socket/2-socket board when rotations coincide), nudge one interior tile. if (isSolved({ n, sockets, source, drain })) { for (let i = 0; i < total; i++) { if (i === source || i === drain) continue; if (sockets[i] !== 0 && sockets[i] !== solution[i]) break; // rotate a tile whose solution orientation is not its only option if (countBits(sockets[i]) === 2) { sockets[i] = rotateSockets(sockets[i], 1); break; } } } return { n, sockets, solution, source, drain, path }; } // ── Board queries ──────────────────────────────────────────────────────────── function countBits(x) { let c = 0; while (x) { x &= x - 1; c++; } return c; } export function isSpecial(i, source, drain) { return i === source || i === drain; } // Set of cell indices reachable from `start` through *matched* sockets // (a socket counts only when the neighbor opens back). This is the wet set. export function wetCells(sockets, n, start) { return new Set(wetOrder(sockets, n, start)); } // BFS order of wet cells from the source — used for the win "wave". export function wetOrder(sockets, n, start) { const seen = new Set([start]); const order = [start]; const stack = [start]; while (stack.length) { const i = stack.pop(); const [r, c] = cellRC(i, n); for (const d of DIRS) { if (!(sockets[i] & d)) continue; const [dr, dc] = DELTA[d]; const nr = r + dr, nc = c + dc; if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue; const ni = nr * n + nc; if (seen.has(ni)) continue; if (!(sockets[ni] & OPP[d])) continue; seen.add(ni); order.push(ni); stack.push(ni); } } return order; } // True if cell i has at least one socket that is a leak (points off the board // or at a neighbor that does not open back). export function tileHasLeak(sockets, n, i) { const [r, c] = cellRC(i, n); for (const d of DIRS) { if (!(sockets[i] & d)) continue; const [dr, dc] = DELTA[d]; const nr = r + dr, nc = c + dc; if (nr < 0 || nr >= n || nc < 0 || nc >= n) return true; if (!(sockets[nr * n + nc] & OPP[d])) return true; } return false; } // Any leak on the board? export function boardHasLeak(sockets, n) { for (let i = 0; i < sockets.length; i++) if (sockets[i] !== 0 && tileHasLeak(sockets, n, i)) return true; return false; } // Solved = every cell is wet (connected to the faucet) AND there are no leaks. export function isSolved(board) { const { n, sockets, source } = board; if (wetCells(sockets, n, source).size !== n * n) return false; if (boardHasLeak(sockets, n)) return false; return true; } // Rotate the tile at index i one step clockwise (specials rotate visually too). export function rotateAt(board, i) { const s = board.sockets[i]; if (s === 0) return board; board.sockets[i] = rotateSockets(s, 1); return board; } // ── Difficulty tiers ───────────────────────────────────────────────────────── export const DIFFICULTIES = [ { key: 'easy', label: 'Easy', n: 4, blurb: '4 × 4 grid' }, { key: 'medium', label: 'Medium', n: 5, blurb: '5 × 5 grid' }, { key: 'hard', label: 'Hard', n: 6, blurb: '6 × 6 grid' }, { key: 'legendary', label: 'Legendary', n: 8, blurb: '8 × 8 grid' }, ]; export function difficultyByKey(key) { return DIFFICULTIES.find((d) => d.key === key) ?? DIFFICULTIES[0]; }