123 lines
6.4 KiB
JavaScript
123 lines
6.4 KiB
JavaScript
// 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);
|