// Headless verification for Rush Hour. // node tools/verifyRushHour.js // Exits non-zero on any failure. // // 1. Solver correctness against an independent reference BFS. // 2. Board model invariants (slideRange, legalMoves, isSolved). // 3. Cluster analysis (distance-to-goal, hardest-state extraction). // 4. Level bank schema and geometry. // 5. Structural criteria (exit row, no frozen lines). // 6. Par matches the solver, and the shipped optimal path replays cleanly. // 7. MINIMAL — removing any vehicle changes the solution. // 8. UNSOLVED — the start is the farthest state from the goal in its cluster. // 9. Curriculum shape (tiers, monotonic par, difficulty metrics). import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { GRID, EXIT_ROW, TARGET_ID, vehicleCells, buildGrid, isSolved, stateKey, legalMoves, slideRange, cloneVehicles, solve, analyzeCluster, packBoard, } from '../src/games/rushhour/RushHourLogic.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); let failures = 0; let checks = 0; function check(name, cond, detail = '') { checks += 1; if (cond) return; failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 62 - title.length))}`); } // ── Reference solver ───────────────────────────────────────────────────────── // // Deliberately naive: clones an array of plain objects per state and keys it // with a string join, exactly the way the shipped solver used to. It exists // only to cross-check the packed rewrite. If these two ever disagree, the fast // path is wrong — this is the whole reason it lives in the verifier rather // than being deleted. function referenceSolve(vehicles, maxStates = 400000) { const start = cloneVehicles(vehicles); if (isSolved(start)) return 0; const seen = new Set([stateKey(start)]); let frontier = [start]; let depth = 0; while (frontier.length) { depth += 1; const next = []; for (const state of frontier) { for (const mv of legalMoves(state)) { const ns = cloneVehicles(state); ns[mv.idx].x = mv.x; ns[mv.idx].y = mv.y; const k = stateKey(ns); if (seen.has(k)) continue; seen.add(k); if (isSolved(ns)) return depth; next.push(ns); } if (seen.size > maxStates) return -1; } frontier = next; if (depth > 120) break; } return -1; } // ── Fixtures ───────────────────────────────────────────────────────────────── // The canonical ThinkFun "card 1" shape: target boxed in behind one truck. const FIXTURE = [ { id: TARGET_ID, x: 1, y: 2, len: 2, orient: 'h', isTarget: true }, { id: 'A', x: 3, y: 1, len: 3, orient: 'v', isTarget: false }, { id: 'B', x: 0, y: 0, len: 2, orient: 'h', isTarget: false }, ]; // ── 1. Solver vs reference ─────────────────────────────────────────────────── section('1. Solver correctness'); { const trivial = [{ id: TARGET_ID, x: 4, y: 2, len: 2, orient: 'h', isTarget: true }]; check('already-solved board reports 0 moves', solve(trivial).moves === 0); check('already-solved board returns an empty path', solve(trivial).path.length === 0); const clear = [{ id: TARGET_ID, x: 0, y: 2, len: 2, orient: 'h', isTarget: true }]; check('unobstructed target solves in one move', solve(clear).moves === 1); // A vertical wall spanning the exit row with no way past is unsolvable. const walled = [ { id: TARGET_ID, x: 0, y: 2, len: 2, orient: 'h', isTarget: true }, { id: 'A', x: 5, y: 0, len: 3, orient: 'v', isTarget: false }, { id: 'B', x: 5, y: 3, len: 3, orient: 'v', isTarget: false }, ]; check('permanently blocked board is unsolvable', solve(walled).moves === -1); check('fixture par matches the reference solver', solve(FIXTURE).moves === referenceSolve(FIXTURE), `fast=${solve(FIXTURE).moves} ref=${referenceSolve(FIXTURE)}`); // A target not on the exit row can never escape. const offRow = [{ id: TARGET_ID, x: 0, y: 3, len: 2, orient: 'h', isTarget: true }]; check('target off the exit row is unsolvable', solve(offRow).moves === -1); // maxStates is a budget, not a claim of unsolvability — but it must not lie // in the other direction by reporting a solution it did not find. const budgeted = solve(FIXTURE, { maxStates: 1 }); check('exhausted budget reports -1 rather than a wrong answer', budgeted.moves === -1 || budgeted.moves === solve(FIXTURE).moves); } // ── 2. Board model invariants ──────────────────────────────────────────────── section('2. Board model'); { const cells = vehicleCells({ x: 2, y: 3, len: 3, orient: 'v' }); check('vertical vehicleCells runs down the column', JSON.stringify(cells) === JSON.stringify([[2, 3], [2, 4], [2, 5]])); const hcells = vehicleCells({ x: 2, y: 3, len: 2, orient: 'h' }); check('horizontal vehicleCells runs along the row', JSON.stringify(hcells) === JSON.stringify([[2, 3], [3, 3]])); const grid = buildGrid(FIXTURE); check('buildGrid marks every occupied square', grid.flat().filter(Boolean).length === FIXTURE.reduce((s, v) => s + v.len, 0)); const r = slideRange(FIXTURE, 0); check('slideRange bounds the target by the blocking truck', r.min === 0 && r.max === 1, `got ${r.min}..${r.max}`); // Every legal move must land somewhere different and stay on the board. const moves = legalMoves(FIXTURE); check('legal moves stay on the board', moves.every((m) => m.x >= 0 && m.y >= 0 && m.x < GRID && m.y < GRID)); check('legal moves actually move a piece', moves.every((m) => { const v = FIXTURE[m.idx]; return m.x !== v.x || m.y !== v.y; })); // packBoard must round-trip positions unchanged. const B = packBoard(FIXTURE); const round = B.toVehicles(B.start); check('packBoard round-trips vehicle positions', round.every((v, i) => v.x === FIXTURE[i].x && v.y === FIXTURE[i].y && v.id === FIXTURE[i].id)); } // ── 3. Cluster analysis ────────────────────────────────────────────────────── section('3. Cluster analysis'); { const C = analyzeCluster(FIXTURE); check('cluster analysis succeeds on a solvable board', !!C); check('start distance equals the solver par', C.startDist === solve(FIXTURE).moves, `dist=${C.startDist} par=${solve(FIXTURE).moves}`); check('hardest state is at least as far as the start', C.maxDist >= C.startDist); check('every state in the cluster can reach the goal', Array.from(C.dist).every((d) => d >= 0)); // The hardest state must really solve in maxDist moves. const hardVehicles = C.toVehicles(C.hardest); check('hardest state solves in exactly maxDist moves', solve(hardVehicles).moves === C.maxDist, `solve=${solve(hardVehicles).moves} maxDist=${C.maxDist}`); // Neighbour distances must differ by at most one — the defining property of // a BFS layering, and the thing the decoy metric relies on. let layered = true; for (let i = 0; i < C.size && layered; i++) { for (const nb of C.neighbors(i)) { if (Math.abs(C.dist[nb.index] - C.dist[i]) > 1) { layered = false; break; } } } check('neighbouring states differ by at most one in distance', layered); } // ── 4-9. The shipped bank ──────────────────────────────────────────────────── const BANK_PATH = join(__dirname, '../assets/gamedata/rushhour/levels.json'); let bank; try { bank = JSON.parse(readFileSync(BANK_PATH, 'utf8')); } catch (err) { console.error(`FAIL level bank is missing or unreadable — ${err.message}`); console.error(' run: node tools/genRushHour.js'); process.exit(1); } section('4. Bank schema and geometry'); { check('bank declares a version', bank.version === 1); check('bank declares tiers', Array.isArray(bank.tiers) && bank.tiers.length > 0); check('count matches the level array', bank.count === bank.levels.length); check('levels are numbered 1..N contiguously', bank.levels.every((p, i) => p.level === i + 1)); const names = new Set(bank.levels.map((p) => p.name)); check('every level has a distinct name', names.size === bank.levels.length); for (const p of bank.levels) { const tag = `level ${p.level}`; const targets = p.vehicles.filter((v) => v.isTarget); check(`${tag}: exactly one target`, targets.length === 1); check(`${tag}: target is horizontal in the exit row`, targets[0]?.orient === 'h' && targets[0]?.y === EXIT_ROW); let ok = true; for (const v of p.vehicles) { if (v.len !== 2 && v.len !== 3) ok = false; if (v.orient !== 'h' && v.orient !== 'v') ok = false; for (const [x, y] of vehicleCells(v)) { if (x < 0 || y < 0 || x >= GRID || y >= GRID) ok = false; } } check(`${tag}: all vehicles are well-formed and on the board`, ok); const occupied = buildGrid(p.vehicles).flat().filter(Boolean).length; check(`${tag}: no two vehicles overlap`, occupied === p.vehicles.reduce((s, v) => s + v.len, 0)); const ids = new Set(p.vehicles.map((v) => v.id)); check(`${tag}: vehicle ids are unique`, ids.size === p.vehicles.length); check(`${tag}: does not start already solved`, !isSolved(p.vehicles)); } } section('5. Structural criteria'); { for (const p of bank.levels) { const tag = `level ${p.level}`; // A horizontal piece sharing the exit row either blocks the exit forever // or is pure decoration; either way it has no place on the board. check(`${tag}: nothing but the target on the exit row`, !p.vehicles.some((v) => !v.isTarget && v.orient === 'h' && v.y === EXIT_ROW)); // A row filled entirely with horizontal pieces (or a column with vertical // ones) can never move. const grid = buildGrid(p.vehicles); const byId = new Map(p.vehicles.map((v) => [v.id, v])); let frozenRow = -1; let frozenCol = -1; for (let y = 0; y < GRID; y++) { if (grid[y].every((id) => id !== null && byId.get(id).orient === 'h')) frozenRow = y; } for (let x = 0; x < GRID; x++) { let all = true; for (let y = 0; y < GRID; y++) { const id = grid[y][x]; if (id === null || byId.get(id).orient !== 'v') { all = false; break; } } if (all) frozenCol = x; } check(`${tag}: no frozen row of horizontal pieces`, frozenRow === -1, `row ${frozenRow}`); check(`${tag}: no frozen column of vertical pieces`, frozenCol === -1, `col ${frozenCol}`); } } section('6. Par is honest'); { for (const p of bank.levels) { const tag = `level ${p.level}`; const { moves, path } = solve(p.vehicles); check(`${tag}: shipped par matches the solver`, moves === p.par, `shipped=${p.par} solver=${moves}`); // Replay the optimal path and confirm it both stays legal and finishes. const state = cloneVehicles(p.vehicles); let legal = true; for (const mv of path ?? []) { const idx = state.findIndex((v) => v.id === mv.id); const range = slideRange(state, idx); const axis = state[idx].orient === 'h' ? mv.x : mv.y; if (axis < range.min || axis > range.max) { legal = false; break; } state[idx].x = mv.x; state[idx].y = mv.y; } check(`${tag}: optimal path is a legal sequence of slides`, legal); check(`${tag}: optimal path ends solved`, legal && isSolved(state)); } } section('7. Every vehicle is load-bearing (MINIMAL)'); { let offenders = 0; for (const p of bank.levels) { const spare = []; for (const v of p.vehicles) { if (v.isTarget) continue; const reduced = p.vehicles.filter((w) => w.id !== v.id); if (solve(reduced).moves === p.par) spare.push(v.id); } if (spare.length) offenders += 1; check(`level ${p.level}: removing any vehicle changes the solution`, spare.length === 0, `redundant: ${spare.join(',')}`); } check('no level in the bank carries a decorative vehicle', offenders === 0, `${offenders} level(s) affected`); } section('8. Start is the hardest arrangement (UNSOLVED)'); { for (const p of bank.levels) { const C = analyzeCluster(p.vehicles); check(`level ${p.level}: cluster analysis succeeds`, !!C); if (!C) continue; check(`level ${p.level}: start is at maximum distance from the goal`, C.startDist === C.maxDist, `start=${C.startDist} max=${C.maxDist}`); } } section('9. Curriculum shape'); { const pars = bank.levels.map((p) => p.par); check('par never decreases as levels advance', pars.every((v, i) => i === 0 || v >= pars[i - 1])); check('the curriculum actually ramps', pars[pars.length - 1] > pars[0] * 2, `${pars[0]} -> ${pars[pars.length - 1]}`); // Tier ranges must tile the bank exactly, in order, with no gaps. let cursor = 1; let tiled = true; for (const t of bank.tiers) { if (t.from !== cursor || t.to < t.from) tiled = false; cursor = t.to + 1; } check('tier ranges tile the bank contiguously', tiled); check('tiers cover every level', cursor - 1 === bank.levels.length); check('every level carries the tier id that contains it', bank.levels.every((p) => { const t = bank.tiers.find((q) => p.level >= q.from && p.level <= q.to); return t && t.id === p.tier; })); // Difficulty must rise between tiers, not just within them. const tierMedian = bank.tiers.map((t) => { const d = bank.levels.filter((p) => p.tier === t.id).map((p) => p.difficulty).sort((a, b) => a - b); return d[Math.floor(d.length / 2)]; }); check('median difficulty rises with every tier', tierMedian.every((v, i) => i === 0 || v > tierMedian[i - 1]), tierMedian.join(' -> ')); // Metrics must be self-consistent with the board they describe. for (const p of bank.levels) { check(`level ${p.level}: carsMoved does not exceed the vehicles present`, p.carsMoved <= p.vehicles.length && p.carsMoved > 0); check(`level ${p.level}: decoyDensity is a fraction`, p.decoyDensity >= 0 && p.decoyDensity <= 1); } const avgShare = bank.levels.reduce((s, p) => s + p.carsMoved / p.vehicles.length, 0) / bank.levels.length; check('on average almost every vehicle has to move', avgShare > 0.85, avgShare.toFixed(3)); } section('10. Fast solver agrees with the reference on every level'); { // The expensive one, so it runs last: an independent BFS over the shipped // bank. Capped to the levels the naive solver can reach in reasonable time. let compared = 0; for (const p of bank.levels) { const ref = referenceSolve(p.vehicles, 300000); if (ref === -1) continue; // reference ran out of budget compared += 1; check(`level ${p.level}: packed solver matches reference BFS`, ref === p.par, `ref=${ref} shipped=${p.par}`); } check('the reference cross-check covered the whole bank', compared === bank.levels.length, `covered ${compared}/${bank.levels.length}`); } // ── Summary ────────────────────────────────────────────────────────────────── console.log(`\n${'─'.repeat(68)}`); const pars = bank.levels.map((p) => p.par); const cars = bank.levels.map((p) => p.vehicles.length); console.log(`levels ${bank.levels.length} par ${Math.min(...pars)}..${Math.max(...pars)} ` + `vehicles ${Math.min(...cars)}..${Math.max(...cars)}`); for (const t of bank.tiers) { const ps = bank.levels.filter((p) => p.tier === t.id).map((p) => p.par); console.log(` ${t.name.padEnd(15)} levels ${String(t.from).padStart(2)}-${String(t.to).padEnd(2)} par ${Math.min(...ps)}..${Math.max(...ps)}`); } console.log(`${'─'.repeat(68)}`); if (failures) { console.error(`\n${failures} FAILED of ${checks} checks`); process.exit(1); } console.log(`\nall ${checks} checks passed`);