fertig-classic-games/tools/verifyJigsaw.js

355 lines
17 KiB
JavaScript

// Headless verification for Jigsaw.
// node tools/verifyJigsaw.js
// Exits non-zero on any failure.
//
// 1. Grid model: seeded determinism, neighbour bounds/symmetry, tab/blank
// complementarity on every internal edge.
// 2. FIT guarantee: for every internal edge of every difficulty, the two
// adjacent pieces trace the *same* shared curve — adjacent pieces physically
// mesh when placed in their correct slots. This is the property the
// table-join rule ("pieces may only join if they fit on the board") relies
// on, so it is checked for ALL grids.
// 3. Table assembly (the production resolveDrop): only grid-adjacent pieces
// join, whole groups are absorbed, fixpoint chains, the rigid-drag
// invariant, board lock (and its precedence over join), and win counting.
import {
DIFFICULTIES, DIFFICULTY_ORDER,
makeJigsaw, cellEdgeSpec, cellNeighbours, edgeFragment, resolveDrop,
} from '../src/games/jigsaw/JigsawLogic.js';
let failures = 0;
function check(ok, msg) {
if (!ok) { failures++; console.error(`${msg}`); }
return ok;
}
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
// ── 1. Grid model ────────────────────────────────────────────────────────────
console.log('Grid model:');
{
const a = makeJigsaw(6, 5, 1234), b = makeJigsaw(6, 5, 1234), c = makeJigsaw(6, 5, 4321);
check(JSON.stringify(a.H) === JSON.stringify(b.H) && JSON.stringify(a.V) === JSON.stringify(b.V),
'same seed must give the same knob layout');
check(JSON.stringify(a.H) !== JSON.stringify(c.H) || JSON.stringify(a.V) !== JSON.stringify(c.V),
'different seeds should give different layouts');
const jig = makeJigsaw(5, 5, 7);
const inB = (r, cc) => r >= 0 && r < 5 && cc >= 0 && cc < 5;
let allInRange = true, symmetric = true;
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 5; cc++) {
for (const [nr, nc] of cellNeighbours(jig, r, cc)) {
if (!inB(nr, nc)) allInRange = false;
if (!cellNeighbours(jig, nr, nc).some(([pr, pc]) => pr === r && pc === cc)) symmetric = false;
}
}
check(allInRange, 'neighbours never leave the grid');
check(symmetric, 'neighbourhood is symmetric');
check(cellNeighbours(jig, 0, 0).length === 2, 'corner piece has 2 neighbours');
check(cellNeighbours(jig, 0, 2).length === 3, 'edge piece has 3 neighbours');
check(cellNeighbours(jig, 2, 2).length === 4, 'interior piece has 4 neighbours');
// Every internal edge is a tab on exactly one side, blank on the other, and
// both sides agree on which way the shared curve bulges.
let complement = true;
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 4; cc++) {
const aL = cellEdgeSpec(jig, r, cc).right, bL = cellEdgeSpec(jig, r, cc + 1).left;
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
}
for (let r = 0; r < 4; r++) for (let cc = 0; cc < 5; cc++) {
const aL = cellEdgeSpec(jig, r, cc).bottom, bL = cellEdgeSpec(jig, r + 1, cc).top;
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
}
check(complement, 'every internal edge: tab/blank pair with a common curve side');
console.log(' ok');
}
// ── 2. Fit guarantee: adjacent pieces trace the identical shared curve ───────
console.log('Fit guarantee (adjacent pieces mesh on the board):');
{
const sample = (p0, p1, edge, n = 128) => {
const pts = [];
let cur = { x: p0.x, y: p0.y };
for (const c of edgeFragment(p0, p1, edge)) {
for (let i = 1; i <= n; i++) {
const t = i / n;
let x, y;
if (c.t === 'line') { x = cur.x + (c.x - cur.x) * t; y = cur.y + (c.y - cur.y) * t; }
else {
const m = 1 - t;
x = m * m * m * cur.x + 3 * m * m * t * c.c1.x + 3 * m * t * t * c.c2.x + t * t * t * c.x;
y = m * m * m * cur.y + 3 * m * m * t * c.c1.y + 3 * m * t * t * c.c2.y + t * t * t * c.y;
}
pts.push({ x, y });
}
cur = { x: c.x, y: c.y };
}
return pts;
};
// Max distance from every sample of curve A to the closest sample of B (both
// ways). Identical curves → near zero (sampling gap only).
const maxGap = (A, B) => Math.max(
...A.map((p) => Math.min(...B.map((q) => Math.hypot(p.x - q.x, p.y - q.y)))),
...B.map((p) => Math.min(...A.map((q) => Math.hypot(p.x - q.x, p.y - q.y))))
);
let edgesChecked = 0, ok = true, worst = 0;
const W = 100, H = 100;
for (const key of DIFFICULTY_ORDER) {
const { cols, rows } = DIFFICULTIES[key];
const jig = makeJigsaw(cols, rows, 42);
for (let r = 0; r < rows; r++) for (let c = 0; c < cols - 1; c++) {
// Vertical internal edge between (r,c) [left] and (r,c+1) [right].
const xA = c * W, yA = r * H;
const p0A = { x: xA + W, y: yA }, p1A = { x: xA + W, y: yA + H };
const p0B = { x: xA + W, y: yA + H }, p1B = { x: xA + W, y: yA };
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).right);
const B = sample(p0B, p1B, cellEdgeSpec(jig, r, c + 1).left);
const gap = maxGap(A, B);
edgesChecked++; worst = Math.max(worst, gap);
if (gap > 2.5) ok = false;
}
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols; c++) {
// Horizontal internal edge between (r,c) [top] and (r+1,c) [bottom].
const xA = c * W, yA = r * H;
const p0A = { x: xA + W, y: yA + H }, p1A = { x: xA, y: yA + H };
const p0B = { x: xA, y: yA + H }, p1B = { x: xA + W, y: yA + H };
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).bottom);
const B = sample(p0B, p1B, cellEdgeSpec(jig, r + 1, c).top);
const gap = maxGap(A, B);
edgesChecked++; worst = Math.max(worst, gap);
if (gap > 2.5) ok = false;
}
}
check(ok, `all ${edgesChecked} internal edges on all difficulties mesh exactly (worst gap ${worst.toFixed(3)}px)`);
console.log(` ok — ${edgesChecked} internal edges checked, worst deviation ${worst.toFixed(4)}px`);
}
// ── 3. Table assembly (production resolveDrop) ───────────────────────────────
console.log('Table assembly (piece joining / group lock):');
{
const SNAP = 0.42; // same SNAP_FRAC as the scene
const boardOrigin = { x: 1000, y: 1000 };
const cell = 100;
function makeBoard(cols = 5, rows = 5, seed = 7) {
const jig = makeJigsaw(cols, rows, seed);
const pieces = [];
const grid = [];
for (let r = 0; r < rows; r++) grid.push(new Array(cols));
let i = 0;
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++, i++) {
const p = {
r, c,
home: { x: boardOrigin.x + (c + 0.5) * cell, y: boardOrigin.y + (r + 0.5) * cell },
// Scattered on the table, well clear of the board and of each other.
pos: { x: 50 + i * 140, y: 5000 + (i % 6) * 120 },
placed: false, group: null,
};
const g = { pieces: [p] };
p.group = g;
pieces.push(p);
grid[r][c] = p;
}
return {
jig, pieces, grid,
groups: new Set(pieces.map((p) => p.group)),
placed: 0, total: pieces.length,
cellAt: (r, c) => (r >= 0 && r < rows && c >= 0 && c < cols) ? grid[r][c] : null,
};
}
const at = (bd, r, c) => bd.grid[r][c];
// Mirror of JigsawGame.handleDrop: apply the decided positions, then do the
// (sound/nudge-free) scene bookkeeping.
function handleDrop(bd, group, anchor) {
const res = resolveDrop(bd.jig, group, bd.cellAt, cell * SNAP);
for (const pl of res.placements) pl.piece.pos = { x: pl.x, y: pl.y };
if (res.outcome === 'locked') {
const locked = group.pieces.filter((m) => !m.placed);
locked.forEach((m) => { m.placed = true; });
bd.groups.delete(group);
bd.placed += locked.length;
} else if (res.outcome === 'joined') {
for (const g of res.absorbedGroups) {
bd.groups.delete(g);
for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); }
}
}
return res;
}
// Mirror of JigsawGame.onPointerMove: rigid group drag around the anchor.
function dragGroup(bd, group, anchor, to) {
for (const m of group.pieces) m.pos = { x: to.x + (m.home.x - anchor.home.x), y: to.y + (m.home.y - anchor.home.y) };
}
const invariantHolds = (group) => group.pieces.every((m) => group.pieces.every((a) =>
approx(m.pos.x, a.pos.x + m.home.x - a.home.x) && approx(m.pos.y, a.pos.y + m.home.y - a.home.y)));
// 3a. resolveDrop is pure: no mutation before the scene applies the result.
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1);
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 20, y: A.pos.y + (B.home.y - A.home.y) - 15 };
const g0 = [...A.group.pieces];
const res = resolveDrop(bd.jig, A.group, bd.cellAt, cell * SNAP);
check(res.outcome === 'joined', '3a: adjacent pair within snap radius joins');
check(res.placements.length === 1 && res.placements[0].piece === B, '3a: only the absorbed piece is placed');
check(A.group.pieces.length === 1 && A.group.pieces[0] === A, '3a: group not mutated by resolveDrop');
check(B.pos.x === 200 + (B.home.x - A.home.x) + 20, '3a: piece positions not mutated by resolveDrop');
const m = res.placements[0];
B.pos = { x: m.x, y: m.y }; // scene-side application
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(B.pos.y, A.pos.y + (B.home.y - A.home.y)),
'3a: absorbed piece snaps to the exact mesh offset');
}
// 3b. Pieces that cannot both sit on the board never join — even when
// dropped dead-on their (hypothetical) meshing offset.
{
const bd = makeBoard();
const A = at(bd, 0, 0), C = at(bd, 0, 2);
A.pos = { x: 300, y: 500 };
C.pos = { x: A.pos.x + 2 * cell, y: A.pos.y }; // exact 2-cell offset, zero error
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3b: non-adjacent pieces never join (rests instead)');
check(A.group.pieces.length === 1, '3b: group stays a singleton');
}
{
const bd = makeBoard();
const A = at(bd, 0, 0), D = at(bd, 1, 1);
A.pos = { x: 300, y: 500 };
D.pos = { x: A.pos.x + cell * 0.7, y: A.pos.y + cell * 0.7 }; // sitting on top, diagonally
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3b: diagonal pieces never join even when overlapping');
}
// 3c. A chain of correctly placed pieces latches on in ONE drop (fixpoint).
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1); // C neighbour of B only
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 10, y: A.pos.y + (B.home.y - A.home.y) };
C.pos = { x: B.pos.x + (C.home.x - B.home.x) - 8, y: B.pos.y + (C.home.y - B.home.y) };
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'joined', '3c: chain joins in one drop');
check(B.group === A.group && C.group === A.group && A.group.pieces.length === 3, '3c: all three in one group');
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(C.pos.x, A.pos.x + (C.home.x - A.home.x)),
'3c: every member snaps to the exact mesh offset');
check(invariantHolds(A.group), '3c: group invariant holds after join');
}
// 3d. A pre-joined group is absorbed WHOLE when a neighbour drops beside it.
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1);
// First: join B+C on the table.
B.pos = { x: 400, y: 600 };
C.pos = { x: B.pos.x + (C.home.x - B.home.x) + 5, y: B.pos.y + (C.home.y - B.home.y) };
const r1 = handleDrop(bd, B.group, B);
check(r1.outcome === 'joined' && B.group.pieces.length === 2, '3d: B+C joined first');
// Then: drop A next to B → the whole B+C group comes along.
const Bg = B.group;
A.pos = { x: B.pos.x - (B.home.x - A.home.x) - 12, y: B.pos.y + 9 };
const r2 = handleDrop(bd, A.group, A);
check(r2.outcome === 'joined', '3d: A dropped beside the pair joins it');
check(B.group === A.group && A.group.pieces.length === 3, '3d: the whole pre-joined group was absorbed');
check(invariantHolds(A.group), '3d: group invariant holds after whole-group absorption');
check(bd.groups.has(A.group) && !bd.groups.has(Bg), '3d: group registry stays consistent');
}
// 3e. Groups drag rigidly: every member keeps its exact home offset.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3), C = at(bd, 1, 2);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
C.pos = { x: A.pos.x + (C.home.x - A.home.x), y: A.pos.y + (C.home.y - A.home.y) };
handleDrop(bd, A.group, A); // B and C both within snap → 3-piece group
check(A.group.pieces.length === 3, '3e: three-piece group formed');
dragGroup(bd, A.group, B, { x: 700, y: 900 }); // grab a non-first member
check(approx(A.pos.x, 700 + (A.home.x - B.home.x)) && approx(C.pos.y, 900 + (C.home.y - B.home.y)),
'3e: dragging any member moves the whole group rigidly');
check(invariantHolds(A.group), '3e: invariant preserved by drag');
}
// 3f. Board lock: any member aligned ⇒ the whole group lands on the board.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
check(A.group.pieces.length === 2, '3f: pair formed');
// Drag the pair (grabbing B, the "far" member) so B lands within snap of home.
dragGroup(bd, A.group, B, { x: B.home.x - 14, y: B.home.y + 10 });
const res = handleDrop(bd, A.group, B);
check(res.outcome === 'locked', '3f: group locks when aligned with the board');
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x) && approx(B.pos.y, B.home.y),
'3f: every member lands exactly on its home slot');
check(A.placed && B.placed && bd.placed === 2, '3f: both members count as placed');
check(!bd.groups.has(A.group), '3f: locked group retired from the table');
}
// 3g. Lock takes precedence over join.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3), D = at(bd, 3, 2);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
// D sits exactly where it would mesh under A (joinable)…
D.pos = { x: A.pos.x + (D.home.x - A.home.x), y: A.pos.y + (D.home.y - A.home.y) };
// …but the A+B pair is also aligned with the board.
dragGroup(bd, A.group, A, { x: A.home.x + 8, y: A.home.y - 6 });
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'locked', '3g: board lock wins over a possible join');
check(D.group.pieces.length === 1 && !D.placed, '3g: the joinable piece was NOT absorbed');
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x), '3g: group landed on the board');
}
// 3h. Outside the snap radius: nothing joins, positions untouched.
{
const bd = makeBoard();
const A = at(bd, 1, 1), B = at(bd, 1, 2);
A.pos = { x: 300, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 60, y: A.pos.y }; // 60 > 42 snap
const Bdrop = { ...B.pos }; // where the drop LEFT it
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3h: beyond snap radius the drop rests');
check(approx(B.pos.x, Bdrop.x) && approx(B.pos.y, Bdrop.y), '3h: unjoined piece keeps its dropped position');
}
// 3i. Placed pieces are ignored by joining.
{
const bd = makeBoard();
const A = at(bd, 1, 1), B = at(bd, 1, 2), D = at(bd, 2, 1);
D.pos = { x: D.home.x, y: D.home.y }; D.placed = true; // already on the board
A.pos = { x: 300, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 4, y: A.pos.y + (B.home.y - A.home.y) };
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'joined' && A.group.pieces.length === 2, '3i: unplaced neighbour still joins');
check(!A.group.pieces.includes(D) && D.placed, '3i: placed piece is never absorbed');
}
// 3j. Win bookkeeping: locking the final pieces reaches the total.
{
const bd = makeBoard(2, 1, 11); // 2 pieces, one internal edge
const A = at(bd, 0, 0), B = at(bd, 0, 1);
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 6, y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
check(A.group.pieces.length === 2, '3j: pair formed');
dragGroup(bd, A.group, A, { x: A.home.x, y: A.home.y });
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'locked' && bd.placed === bd.total, '3j: locking the pair completes the board');
}
console.log(' ok');
}
if (failures) {
console.error(`\nFAILED: ${failures} check(s).`);
process.exit(1);
}
console.log('\nAll Jigsaw checks passed.');