// Headless verification for Pipe Puzzle. // node tools/verifyPipePuzzle.js // Exits non-zero on any failure. // // 1. Fixture tests: socket algebra + a hand-built no-leak board. // 2. Generation invariant sweep: for many random puzzles at every difficulty, // the solution board has no leaks, the board is connected, the piece mix // is rich, and the scrambled board starts with leaks. import { N, E, S, W, DIRS, OPP, rotateSockets, bitCount, generatePuzzle, isSolved, countLeaks, wetOrder, DIFFICULTIES, } from '../src/games/pipepuzzle/PipePuzzleLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); } else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } } // ── 1. Fixtures ───────────────────────────────────────────────────────────── console.log('\n— Socket algebra —'); check('N/E/S/W flags distinct', new Set([N, E, S, W]).size === 4); check('OPP is an involution', DIRS.every((d) => OPP[OPP[d]] === d)); check('rotateSockets 4 steps = identity', [1, 2, 4, 8, 3, 5, 6, 10, 12, 9, 15].every((s) => rotateSockets(s, 4) === s)); check('rotateSockets N→E→S→W', rotateSockets(N, 1) === E && rotateSockets(N, 2) === S && rotateSockets(N, 3) === W); check('rotateSockets elbow NE→ES→SW→WN', rotateSockets(N | E, 1) === (E | S) && rotateSockets(N | E, 2) === (S | W) && rotateSockets(N | E, 3) === (W | N)); check('rotateSockets T N|E|W → N|S|E', rotateSockets(N | E | W, 1) === (N | S | E)); check('rotateSockets cross = invariant', rotateSockets(N | E | S | W, 3) === (N | E | S | W)); // A solved 3×3 no-leak board (row-major 0..8) — a plain spanning tree (8 // edges, no cycles, no 4-way cell) with two T-pieces and four dead ends: // 0=E (source) 1=W|E|S (T) 2=W (stub) // 3=E (stub) 4=N|W|E (T) 5=W|S (elbow) // 6=E (drain) 7=E|W (straight) 8=N|W (elbow) // Every socket is matched to a neighbour that opens back, so no leaks. { const n = 3; const sockets = [E, W | E | S, W, E, N | W | E, W | S, E, E | W, N | W]; const board = { n, sockets, source: 0, drain: 6 }; check('fixture 3×3 solved (no leaks)', isSolved(board) === true); check('fixture 3×3 has a T-piece (3 sockets)', sockets.filter((s) => bitCount(s) === 3).length >= 1); check('fixture 3×3 has no cross (4 sockets)', !sockets.some((s) => bitCount(s) === 4)); // Break one connection: rotate the stub at cell2 W → N (points off the // top wall). Off-board socket = guaranteed leak. board.sockets = [E, W | E | S, N, E, N | W | E, W | S, E, E | W, N | W]; check('fixture 3×3 after rotation not solved', isSolved(board) === false); check('fixture 3×3 after rotation has ≥1 leak', countLeaks(board.sockets, n) >= 1); } // ── 2. Generation invariant sweep ─────────────────────────────────────────── function isConnected(n, sockets) { const total = n * n; const parent = new Array(total); for (let i = 0; i < total; i++) parent[i] = i; const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; for (let i = 0; i < total; i++) { for (const d of DIRS) { if (!(sockets[i] & d)) continue; const [dr, dc] = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }[d]; const nr = Math.floor(i / n) + dr, nc = (i % n) + dc; if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue; const j = nr * n + nc; if (!(sockets[j] & OPP[d])) continue; const ra = find(i), rb = find(j); if (ra !== rb) parent[ra] = rb; } } const root = find(0); for (let i = 1; i < total; i++) if (find(i) !== root) return false; return true; } // Number of matched edges on the board (a tree over n² cells has exactly n²−1). function matchedEdgeCount(n, sockets) { let edges = 0; for (let i = 0; i < n * n; i++) { if (sockets[i] & E) edges++; // count each E-neighbour once if (sockets[i] & S) edges++; // count each S-neighbour once } return edges; } console.log('\n— Generation invariants —'); for (const diff of DIFFICULTIES) { const n = diff.n; const samples = 30; let solNoLeak = 0, scrLeak = 0, connOk = 0, wetAll = 0, treeOk = 0, noCross = 0; const solutions = new Set(); const leafRowsSeen = new Array(n).fill(false); const srcRowsSeen = new Array(n).fill(false); for (let s = 0; s < samples; s++) { const p = generatePuzzle(n); if (countLeaks(p.solution, n) === 0) solNoLeak++; if (countLeaks(p.sockets, n) > 0) scrLeak++; if (isConnected(n, p.solution)) connOk++; if (wetOrder(p.solution, n, p.source).length === n * n) wetAll++; // A spanning tree: exactly n²−1 matched edges → no cycles → one unique // route between every pair of cells (faucet → each dead end). if (matchedEdgeCount(n, p.solution) === n * n - 1) treeOk++; if (!p.solution.some((sk) => bitCount(sk) === 4)) noCross++; for (let i = 0; i < n * n; i++) if (bitCount(p.solution[i]) === 1) leafRowsSeen[Math.floor(i / n)] = true; srcRowsSeen[Math.floor(p.source / n)] = true; solutions.add(JSON.stringify(p.solution)); } const leafRowsAll = leafRowsSeen.every(Boolean); check(`${diff.key} (${n}×${n}): solution board has no leaks (${solNoLeak}/${samples})`, solNoLeak === samples); check(`${diff.key} (${n}×${n}): board is connected (${connOk}/${samples})`, connOk === samples); check(`${diff.key} (${n}×${n}): faucet reaches every cell (${wetAll}/${samples})`, wetAll === samples); check(`${diff.key} (${n}×${n}): solution is a tree — exactly ${n * n - 1} edges, no loops (${treeOk}/${samples})`, treeOk === samples); check(`${diff.key} (${n}×${n}): no 4-way crosses on the board (${noCross}/${samples})`, noCross === samples); check(`${diff.key} (${n}×${n}): dead ends appear in every row across boards`, leafRowsAll); check(`${diff.key} (${n}×${n}): faucet appears in more than one row across boards`, srcRowsSeen.filter(Boolean).length >= 2); check(`${diff.key} (${n}×${n}): boards are genuinely random (≥15 distinct of ${samples})`, solutions.size >= 15); check(`${diff.key} (${n}×${n}): scrambled board starts with leaks (${scrLeak}/${samples})`, scrLeak === samples); } console.log(failures === 0 ? '\nAll Pipe Puzzle checks passed.' : `\n${failures} check(s) FAILED.`); process.exit(failures === 0 ? 0 : 1);