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