fertig-classic-games/server/scripts/verifyMiniMotorways.js

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);