381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
// 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);
|