261 lines
10 KiB
JavaScript
261 lines
10 KiB
JavaScript
// 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];
|
||
}
|