fertig-classic-games/tools/verifyPipePuzzle.js

123 lines
6.4 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) — 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);