94 lines
4.6 KiB
JavaScript
94 lines
4.6 KiB
JavaScript
// Headless verification for Pipe Puzzle.
|
||
// node tools/verifyPipePuzzle.js
|
||
// Exits non-zero on any failure.
|
||
//
|
||
// 1. Fixture tests: hand-built boards, rotation algebra, win check.
|
||
// 2. Generation invariant sweep: for many random puzzles at every
|
||
// difficulty, the generated path is a valid Hamiltonian path and the
|
||
// solution board is solved while the scrambled board is not.
|
||
|
||
import {
|
||
N, E, S, W, DIRS, OPP, DELTA,
|
||
rotateSockets, generatePuzzle, isSolved, wetCells,
|
||
boardHasLeak, tileHasLeak, randomHamiltonianPath, gridAdjacent,
|
||
cellRC, 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));
|
||
|
||
// A solved 2×2 board (row-major: 0=(0,0) 1=(0,1) / 2=(1,0) 3=(1,1)):
|
||
// source cell0 → cell1 → cell3 → drain cell2.
|
||
// Sockets (solution): cell0 = E (source, 1 socket), cell1 = W|S, cell2 = E
|
||
// (drain, 1 socket), cell3 = N|W.
|
||
{
|
||
const n = 2;
|
||
const sockets = [E, W | S, E, N | W];
|
||
const board = { n, sockets, source: 0, drain: 2 };
|
||
check('fixture 2×2 solved', isSolved(board) === true);
|
||
check('fixture 2×2 wet = all cells', wetCells(sockets, n, 0).size === 4);
|
||
check('fixture 2×2 no leaks', boardHasLeak(sockets, n) === false);
|
||
|
||
// Rotate the source: E → S. Now it points at cell2 (which has only E),
|
||
// so the source leaks and the board is unsolved.
|
||
board.sockets = [S, W | S, E, N | W];
|
||
check('fixture 2×2 after rotation not solved', isSolved(board) === false);
|
||
check('fixture 2×2 after rotation has leak', boardHasLeak(board.sockets, n) === true);
|
||
}
|
||
|
||
// A leaked board: single socket pointing at wall
|
||
{
|
||
const n = 3;
|
||
const sockets = [N, 0, 0, 0, 0, 0, 0, 0, 0];
|
||
check('wall socket is a leak', tileHasLeak(sockets, n, 0) === true);
|
||
}
|
||
|
||
// ── 2. Generation invariant sweep ───────────────────────────────────────────
|
||
console.log('\n— Generation invariants —');
|
||
for (const diff of DIFFICULTIES) {
|
||
const n = diff.n;
|
||
const samples = 40;
|
||
let pathValid = 0, solutionSolved = 0, scrambledUnsolved = 0, tileCounts = 0;
|
||
|
||
for (let s = 0; s < samples; s++) {
|
||
const path = randomHamiltonianPath(n);
|
||
const total = n * n;
|
||
const isPerm = new Set(path).size === total && path.length === total &&
|
||
path.every((i) => Number.isInteger(i) && i >= 0 && i < total);
|
||
const adjacent = path.slice(0, -1).every((c, i) => gridAdjacent(c, path[i + 1], n));
|
||
if (isPerm && adjacent) pathValid++;
|
||
|
||
const p = generatePuzzle(n);
|
||
// Solution board must be solved.
|
||
if (isSolved({ n, sockets: p.solution, source: p.source, drain: p.drain })) solutionSolved++;
|
||
// Scrambled board must not already be solved.
|
||
if (!isSolved({ n, sockets: p.sockets, source: p.source, drain: p.drain })) scrambledUnsolved++;
|
||
// Tile socket counts: 1 for source/drain, 2 for the rest.
|
||
const countsOk = p.sockets.every((sk, i) => {
|
||
const bits = (x) => { let c = 0; while (x) { x &= x - 1; c++; } return c; };
|
||
if (i === p.source || i === p.drain) return bits(sk) === 1;
|
||
return bits(sk) === 2;
|
||
});
|
||
if (countsOk) tileCounts++;
|
||
}
|
||
|
||
check(`${diff.key} (${n}×${n}): path is a valid Hamiltonian path (${pathValid}/${samples})`, pathValid === samples);
|
||
check(`${diff.key} (${n}×${n}): solution board is solved (${solutionSolved}/${samples})`, solutionSolved === samples);
|
||
check(`${diff.key} (${n}×${n}): scrambled board starts unsolved (${scrambledUnsolved}/${samples})`, scrambledUnsolved === samples);
|
||
check(`${diff.key} (${n}×${n}): socket counts 1/1/2…/2 (${tileCounts}/${samples})`, tileCounts === samples);
|
||
}
|
||
|
||
console.log(failures === 0 ? '\nAll Pipe Puzzle checks passed.' : `\n${failures} check(s) FAILED.`);
|
||
process.exit(failures === 0 ? 0 : 1);
|