fertig-classic-games/src/games/pipepuzzle/PipePuzzleLogic.js

261 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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