101 lines
5.0 KiB
JavaScript
101 lines
5.0 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):
|
||
// 0=E (source) 1=W|E|S (T) 2=W (stub)
|
||
// 3=E|S (elbow) 4=N|E|S|W (cross) 5=W (stub)
|
||
// 6=N (stub) 7=N|E (elbow) 8=W (drain)
|
||
// Every socket is matched to a neighbour that opens back — no leaks.
|
||
{
|
||
const n = 3;
|
||
const sockets = [E, W | E | S, W, E | S, N | E | S | W, W, N, N | E, W];
|
||
const board = { n, sockets, source: 0, drain: 8 };
|
||
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 a cross (4 sockets)', sockets.some((s) => bitCount(s) === 4));
|
||
|
||
// Break one connection: rotate the stub at cell2 W → N (points off the wall).
|
||
board.sockets = [E, W | E | S, N, E | S, N | E | S | W, W, N, N | E, 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;
|
||
}
|
||
|
||
console.log('\n— Generation invariants —');
|
||
for (const diff of DIFFICULTIES) {
|
||
const n = diff.n;
|
||
const samples = 30;
|
||
let solNoLeak = 0, scrLeak = 0, mixOk = 0, connOk = 0, wetAll = 0;
|
||
|
||
for (let s = 0; s < samples; s++) {
|
||
const p = generatePuzzle(n, diff.extra ?? 0);
|
||
if (countLeaks(p.solution, n) === 0) solNoLeak++;
|
||
if (countLeaks(p.sockets, n) > 0) scrLeak++;
|
||
const kinds = p.solution.map((sk) => bitCount(sk));
|
||
const tc = kinds.filter((d) => d >= 3).length;
|
||
const stubs = kinds.filter((d) => d === 1).length;
|
||
if (tc >= 3 && stubs >= 3) mixOk++;
|
||
if (isConnected(n, p.solution)) connOk++;
|
||
if (wetOrder(p.solution, n, p.source).length === n * n) wetAll++;
|
||
}
|
||
|
||
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}): rich mix — ≥3 T/cross + ≥3 dead-ends (${mixOk}/${samples})`, mixOk === samples);
|
||
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);
|