393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
// 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`);
|