// Headless verification for Mini Motorways. // node server/scripts/verifyMiniMotorways.js // Exits non-zero on any failure. // // 1. City generation invariants for all six cities. // 2. Road adjacency fixtures (diagonal crossing rule, costs). // 3. Pathfinder fixtures (straight runs, bridges, motorway shortcuts). // 4. Overflow with no roads ends the game. // 5. Monte-carlo bot: 15 simulated weeks per city with invariant checks. import { WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES, Sim, generateCity, keyOf, xOf, yOf, octile, } from '../../public/src/games/minimotorways/MiniMotorwaysLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures++; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } // A sim stripped to bare terrain for hand-built network fixtures. function fixtureSim() { const sim = new Sim(0, 12345); sim.terrain.fill(TERRAIN.LAND); sim.houses = []; sim.buildings = []; sim.cars = []; sim.roads.clear(); sim.bridgeCells.clear(); sim.bridges = []; sim.items.clear(); sim.motorways = []; sim.portals.clear(); sim.cellOcc.clear(); sim.touchNetwork(); return sim; } // ── 1. City generation ───────────────────────────────────────────────────────── console.log('City generation'); { const r = { x0: (WORLD_W - 20) / 2, y0: (WORLD_H - 12) / 2, w: 20, h: 12 }; for (let ci = 0; ci < CITIES.length; ci++) { for (const seed of [1, 777, 424242]) { const { terrain } = generateCity(ci, seed); let bad = terrain.length !== WORLD_W * WORLD_H; let water = 0; let startWater = 0; for (let i = 0; i < terrain.length; i++) { if (terrain[i] > 2) bad = true; if (terrain[i] === TERRAIN.WATER) { water++; const x = xOf(i); const y = yOf(i); if (x >= r.x0 && x < r.x0 + r.w && y >= r.y0 && y < r.y0 + r.h) startWater++; } } check(`${CITIES[ci].name} seed ${seed}: terrain valid`, !bad); check(`${CITIES[ci].name} seed ${seed}: start rect ≥80% land`, startWater <= r.w * r.h * 0.2, `water=${startWater}`); const expectsWater = CITIES[ci].gen.rivers > 0 || CITIES[ci].gen.lakes > 0; if (expectsWater) check(`${CITIES[ci].name} seed ${seed}: has water`, water > 0); } } } // ── 2. Adjacency ─────────────────────────────────────────────────────────────── console.log('Adjacency / crossing rule'); { const sim = fixtureSim(); // 2x2 all-road square: diagonals must be suppressed, orthogonals intact. const square = [keyOf(5, 5), keyOf(6, 5), keyOf(5, 6), keyOf(6, 6)]; for (const k of square) sim.roads.add(k); sim.touchNetwork(); let diagonals = 0; let orthogonals = 0; for (const k of square) { for (const nb of sim.roadNeighbors(k)) { if (Math.abs(nb.cost - Math.SQRT2) < 0.001) diagonals++; else orthogonals++; } } check('2x2 square has zero diagonal edges', diagonals === 0, `diag=${diagonals}`); check('2x2 square fully orthogonally connected', orthogonals === 8, `orth=${orthogonals}`); // Diagonal staircase keeps its diagonal edges at cost √2. const sim2 = fixtureSim(); const stairs = [keyOf(10, 10), keyOf(11, 11), keyOf(12, 12)]; for (const k of stairs) sim2.roads.add(k); sim2.touchNetwork(); const mid = sim2.roadNeighbors(keyOf(11, 11)); check('staircase middle has two diagonal edges', mid.length === 2); check('diagonal edge costs √2', mid.every((nb) => Math.abs(nb.cost - Math.SQRT2) < 0.001)); // Symmetry: every edge runs both ways. const sim3 = fixtureSim(); for (let i = 0; i < 60; i++) { sim3.roads.add(keyOf(2 + Math.floor(Math.random() * 20), 2 + Math.floor(Math.random() * 15))); } sim3.touchNetwork(); let asym = 0; for (const k of sim3.roads) { for (const nb of sim3.roadNeighbors(k)) { if (!sim3.roadNeighbors(nb.k).some((b) => b.k === k)) asym++; } } check('random network adjacency is symmetric', asym === 0, `asym=${asym}`); } // ── 3. Pathfinding ───────────────────────────────────────────────────────────── console.log('Pathfinding'); { const sim = fixtureSim(); for (let x = 1; x <= 10; x++) sim.roads.add(keyOf(x, 1)); sim.touchNetwork(); const path = sim.findPath([keyOf(1, 1)], [keyOf(10, 1)]); check('straight 10-cell road: path found', !!path); check('straight path has 10 cells', path && path.length === 10, `len=${path?.length}`); // Bridge across a 2-wide channel. const sim2 = fixtureSim(); for (let y = 0; y < WORLD_H; y++) { sim2.terrain[keyOf(12, y)] = TERRAIN.WATER; sim2.terrain[keyOf(13, y)] = TERRAIN.WATER; } for (let x = 8; x <= 11; x++) sim2.roads.add(keyOf(x, 5)); for (let x = 14; x <= 17; x++) sim2.roads.add(keyOf(x, 5)); sim2.touchNetwork(); check('channel blocks path', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null); sim2.stock.bridges = 1; const span = [keyOf(12, 5), keyOf(13, 5)]; check('bridge placement allowed', sim2.canPlaceBridge(span)); sim2.placeBridge(span); const over = sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]); check('bridge connects the banks', !!over && over.length === 10, `len=${over?.length}`); check('bridge cells marked', sim2.bridgeCells.has(span[0]) && sim2.bridgeCells.has(span[1])); sim2.eraseBridgeAt(span[0]); check('erasing bridge disconnects again', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null); check('bridge stock refunded', sim2.stock.bridges === 1); // Motorway shortcut. const sim3 = fixtureSim(); for (let x = 1; x <= 30; x++) sim3.roads.add(keyOf(x, 1)); sim3.touchNetwork(); sim3.stock.motorways = 1; const a = keyOf(1, 2); const b = keyOf(30, 2); check('motorway placement allowed', sim3.canPlaceMotorway(a, b)); sim3.placeMotorway(a, b); const quick = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]); check('motorway path found', !!quick); check('path takes the portal', quick && quick.includes(a) && quick.includes(b)); check('portal route is short', quick && quick.length <= 6, `len=${quick?.length}`); sim3.eraseMotorwayAt(a); const slow = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]); check('after erase, path is the long way', !!slow && slow.length === 30, `len=${slow?.length}`); check('motorway stock refunded', sim3.stock.motorways === 1); // Intersection items need ≥3 connections. const sim4 = fixtureSim(); sim4.roads.add(keyOf(5, 5)); sim4.roads.add(keyOf(4, 5)); sim4.roads.add(keyOf(6, 5)); sim4.touchNetwork(); sim4.stock.lights = 1; check('light rejected on straight road', !sim4.canPlaceItem(keyOf(5, 5), 'light')); sim4.roads.add(keyOf(5, 4)); sim4.touchNetwork(); check('light allowed on T-junction', sim4.canPlaceItem(keyOf(5, 5), 'light')); } // ── 4. Overflow ends the game ────────────────────────────────────────────────── console.log('Overflow → game over'); { const sim = new Sim(0, 99); let gameOverEvent = null; let guard = 0; while (!sim.gameOver && guard++ < 10000) { const events = sim.step(100); for (const e of events) { if (e.type === 'weekEnd') sim.chooseUpgrade(0); if (e.type === 'gameOver') gameOverEvent = e; } } check('roadless city overflows to game over', sim.gameOver); check('game over event emitted with culprit', !!gameOverEvent && gameOverEvent.buildingId > 0); check('overflow timing plausible', sim.time > 60000 && sim.time < 400000, `t=${sim.time}`); check('score stayed at zero', sim.score === 0); } // ── 5. Monte-carlo bot ───────────────────────────────────────────────────────── console.log('Monte-carlo bot, 15 weeks per city'); // Weighted search over buildable cells (4-connected) between two structures, // then pave the path. Water is allowed at a steep cost; runs of water ≤3 cells // become bridges, longer runs abort the connection. function botConnect(sim, fromCells, toCells) { const from = Array.isArray(fromCells) ? fromCells : [fromCells]; const to = new Set(Array.isArray(toCells) ? toCells : [toCells]); const structs = new Set([...from, ...to]); const occ = sim.buildOccupiedSet(); const cellCost = (k) => { if (structs.has(k)) return 1; if (sim.terrain[k] === TERRAIN.WATER) return sim.roads.has(k) ? 1 : 6; if (sim.terrain[k] === TERRAIN.LAND && !occ.has(k)) return 1; return Infinity; }; const dist = new Map(); const prev = new Map(); const open = [...from.map((k) => [0, k])]; for (const k of from) dist.set(k, 0); let hit = null; while (open.length && hit === null) { open.sort((a, b) => a[0] - b[0]); const [d, k] = open.shift(); if (d > (dist.get(k) ?? Infinity)) continue; if (to.has(k)) { hit = k; break; } const x = xOf(k); const y = yOf(k); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= WORLD_W || ny < 0 || ny >= WORLD_H) continue; const nk = keyOf(nx, ny); const c = cellCost(nk); if (c === Infinity) continue; const nd = d + c; if (nd < (dist.get(nk) ?? Infinity)) { dist.set(nk, nd); prev.set(nk, k); open.push([nd, nk]); } } } if (hit === null) return false; const path = []; for (let k = hit; k !== undefined; k = prev.get(k)) path.push(k); path.reverse(); // Validate water runs first: each must be ≤3 cells and straight. const runs = []; let run = []; for (const k of path) { if (sim.terrain[k] === TERRAIN.WATER && !sim.roads.has(k)) { run.push(k); continue; } if (run.length) { runs.push(run); run = []; } } if (run.length) runs.push(run); for (const r of runs) { if (r.length > 3) return false; const straight = r.every((k) => xOf(k) === xOf(r[0])) || r.every((k) => yOf(k) === yOf(r[0])); if (!straight) return false; } for (const r of runs) { if (sim.stock.bridges < 1 || !sim.placeBridge(r)) return false; } for (const k of path) { if (!structs.has(k) && !sim.roads.has(k) && sim.terrain[k] !== TERRAIN.WATER) sim.placeRoad(k); } return true; } function botHandleSpawn(sim, e) { if (e.type === 'houseSpawn') { const targets = sim.buildings.filter((b) => b.color === e.color); targets.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k)) - octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k))); for (const t of targets.slice(0, 4)) { if (botConnect(sim, e.k, t.cells)) return; } } else if (e.type === 'buildingSpawn') { const building = sim.buildings.find((b) => b.id === e.id); const homes = sim.houses.filter((h) => h.color === e.color && h.k !== e.k); homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k)) - octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k))); for (const h of homes.slice(0, 4)) { if (botConnect(sim, h.k, building.cells)) return; } } } // Late repairs: any building accumulating pins with nothing en route gets a // fresh connection attempt to its nearest same-colour houses. function botRescue(sim) { for (const b of sim.buildings) { if (b.pins < 3 || (b.reserved > 0 && b.pins < 5)) continue; const homes = sim.houses.filter((h) => h.color === b.color); homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(b.k), yOf(b.k)) - octile(xOf(q.k), yOf(q.k), xOf(b.k), yOf(b.k))); for (const h of homes.slice(0, 3)) { if (botConnect(sim, h.k, b.cells)) break; } } } function checkInvariants(sim, label) { for (const car of sim.cars) { if (Number.isNaN(car.x) || Number.isNaN(car.y) || Number.isNaN(car.pos)) { return `${label}: NaN in car ${car.id} (${car.state})`; } } if (sim.cars.length > TUNE.CAR_CAP) return `${label}: car cap exceeded (${sim.cars.length})`; for (const b of sim.buildings) { if (b.reserved > b.pins) return `${label}: reserved ${b.reserved} > pins ${b.pins} at building ${b.id}`; if (b.ring < 0 || b.ring > 1.2) return `${label}: ring out of range ${b.ring}`; } for (const k of sim.roads) { for (const nb of sim.roadNeighbors(k)) { if (!sim.roadNeighbors(nb.k).some((x) => x.k === k)) { return `${label}: asymmetric edge ${k}→${nb.k}`; } } } return null; } { const targetWeeks = 15; for (let ci = 0; ci < CITIES.length; ci++) { const sim = new Sim(ci, 1000 + ci); sim.stock.roads = 99999; sim.stock.bridges += 50; // Wire up the structures spawned during construction. for (const h of sim.houses) { const targets = sim.buildings.filter((b) => b.color === h.color); if (targets.length) botConnect(sim, h.k, targets[0].cells); } let lastScore = 0; let scoreRegressed = false; let invariantError = null; let steps = 0; let stranded = 0; while (sim.week < targetWeeks && !sim.gameOver && steps < 30000) { const events = sim.step(100); steps++; for (const e of events) { if (e.type === 'weekEnd') sim.chooseUpgrade(0); else if (e.type === 'houseSpawn' || e.type === 'buildingSpawn' || e.type === 'colorUnlock') { if (e.type !== 'colorUnlock') botHandleSpawn(sim, e); } else if (e.type === 'stranded') stranded++; } if (sim.score < lastScore) scoreRegressed = true; lastScore = sim.score; // Exercise erase + reroute: pull a road cell out from under traffic, // then put it back two ticks later. if (steps % 600 === 300 && sim.roads.size > 10) { const plain = [...sim.roads].filter((k) => !sim.bridgeCells.has(k) && !sim.portals.has(k)); if (plain.length) { const victim = plain[Math.floor(Math.random() * plain.length)]; sim.eraseRoad(victim); sim.step(100); steps++; sim.placeRoad(victim); } } if (steps % 100 === 0) botRescue(sim); if (steps % 50 === 0 && !invariantError) { invariantError = checkInvariants(sim, `${CITIES[ci].name} step ${steps}`); } } const name = CITIES[ci].name; check(`${name}: no invariant violations`, !invariantError, invariantError ?? ''); check(`${name}: score is positive`, sim.score > 0, `score=${sim.score}`); check(`${name}: score never regressed`, !scoreRegressed); check(`${name}: survived or died legitimately`, sim.week >= targetWeeks || sim.gameOver, `week=${sim.week} steps=${steps}`); console.log(` ${name}: weeks=${sim.week} score=${sim.score} cars=${sim.cars.length} ` + `houses=${sim.houses.length} buildings=${sim.buildings.length} roads=${sim.roads.size} ` + `stranded=${stranded} gameOver=${sim.gameOver}`); } } console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); process.exit(failures ? 1 : 0);