fertig-classic-games/tools/verifyPipePuzzle.js

101 lines
5.0 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.

// 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);