fertig-classic-games/tools/verifyWolfenstein.js

1344 lines
74 KiB
JavaScript

#!/usr/bin/env node
// Verifies the Wolfenstein engine end to end, headlessly.
//
// node tools/verifyWolfenstein.js [--quick]
//
// WolfensteinRules/WolfensteinRaycaster/WolfensteinLogic import no Phaser. Sections 1-5 use a
// small hand-built synthetic test map so raycaster distances and swept-bullet-collision
// outcomes can be asserted against hand-computed expected values. Sections 6+ (added once the
// authoring pipeline exists) validate the actual shipped level/campaign JSON.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js';
import { castRay, hasLineOfSight, makeCamera, castColumns, secretDoorSlab } from '../src/games/wolfenstein/WolfensteinRaycaster.js';
import * as L from '../src/games/wolfenstein/WolfensteinLogic.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUICK = process.argv.includes('--quick');
const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/wolfenstein-rules.json'), 'utf8'));
const rules = compileRules(rulesJson);
let pass = 0; const failures = [];
function check(name, cond, detail) {
if (cond) { pass++; return true; }
failures.push(detail ? `${name}${detail}` : name);
return false;
}
function section(title) { console.log(`\n── ${title}`); }
function near(a, b, eps = 1e-3) { return Math.abs(a - b) < eps; }
// ---------------------------------------------------------------------------
section('1. Rules integrity');
// ---------------------------------------------------------------------------
{
check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol);
check('shotgun weapon defined', !!rules.weaponById.shotgun);
check('machinegun weapon defined', !!rules.weaponById.machinegun);
check('gatling weapon defined', !!rules.weaponById.gatling);
check('plasmarifle weapon defined', !!rules.weaponById.plasmarifle);
check('guard enemy defined', !!rules.enemyById.guard);
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
check('shells ammo type defined', !!rules.ammoTypeById.shells);
check('plasma ammo type defined', !!rules.ammoTypeById.plasma);
for (const w of rules.weapons) {
check(`${w.id} has a valid fireMode`, ['auto', 'semi', 'burst'].includes(w.fireMode), w.fireMode);
if (w.fireMode === 'burst') {
check(`${w.id} has a positive burstCount`, w.burstCount > 0);
check(`${w.id} has a positive burstIntervalMs`, w.burstIntervalMs > 0);
} else {
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
}
if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
if (w.kind === 'projectile') {
const at = rules.ammoTypeById[w.ammoType];
check(`${w.id} references a defined ammo type`, !!at, w.ammoType);
if (at) {
check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
check(`${w.id}'s ammo type has a positive maxAmmo`, at.maxAmmo > 0, at.maxAmmo);
}
}
}
check('shotgun damage range is ~5x the pistol\'s', (() => {
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const s = rules.ammoTypeById[rules.weaponById.shotgun.ammoType];
return s.damageMin === p.damageMin * 5 && s.damageMax === p.damageMax * 5;
})());
check('plasma rifle damage range is ~2x the pistol\'s', (() => {
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const pl = rules.ammoTypeById[rules.weaponById.plasmarifle.ammoType];
return pl.damageMin === p.damageMin * 2 && pl.damageMax === p.damageMax * 2;
})());
for (const it of rules.items) {
check(`item ${it.id} has a valid frame index`, Number.isInteger(it.frame) && it.frame >= 0, it.frame);
if (it.kind === 'weapon') check(`item ${it.id}'s grantsWeapon resolves to a defined weapon`, !!rules.weaponById[it.grantsWeapon], it.grantsWeapon);
if (it.kind === 'ammo') check(`item ${it.id}'s ammoType resolves to a defined ammo type`, !!rules.ammoTypeById[it.ammoType], it.ammoType);
if (it.kind === 'health') check(`item ${it.id} has a positive amount`, it.amount > 0);
}
for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange);
if (e.rangedWeapon) check(`${e.id}'s rangedWeapon resolves to a defined weapon`, !!rules.weaponById[e.rangedWeapon], e.rangedWeapon);
}
check('tickHz positive', rules.constants.tickHz > 0);
check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz));
}
// ---------------------------------------------------------------------------
// A small synthetic 8x8 test map, hand-computable:
// row0: border row3: internal wall at columns 3-4 (a 2-cell-thick wall)
// rows1-6: open interior except row3's columns 3-4
// row7: border
// ---------------------------------------------------------------------------
function makeTestMap() {
const W = 8, H = 8;
const walls = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
const border = x === 0 || y === 0 || x === W - 1 || y === H - 1;
const internal = y === 3 && (x === 3 || x === 4);
row.push(border || internal ? 1 : 0);
}
walls.push(row);
}
return { width: W, height: H, walls };
}
// ---------------------------------------------------------------------------
section('2. Raycaster correctness (hand-computed distances)');
// ---------------------------------------------------------------------------
{
const map = makeTestMap();
const east = castRay(map, 1.5, 1.5, 1, 0);
check('east ray hits east boundary', east && east.mapX === 7 && east.mapY === 1, JSON.stringify(east));
check('east ray perpDist matches hand calc', east && near(east.perpDist, 5.5), east?.perpDist);
check('east ray is an X-side hit', east && east.side === 0);
const south = castRay(map, 1.5, 1.5, 0, 1);
check('south ray hits south boundary', south && south.mapX === 1 && south.mapY === 7, JSON.stringify(south));
check('south ray perpDist matches hand calc', south && near(south.perpDist, 5.5), south?.perpDist);
check('south ray is a Y-side hit', south && south.side === 1);
const internal = castRay(map, 1.5, 3.5, 1, 0);
check('ray hits internal wall, not the far boundary', internal && internal.mapX === 3 && internal.mapY === 3, JSON.stringify(internal));
check('internal wall perpDist matches hand calc', internal && near(internal.perpDist, 1.5), internal?.perpDist);
// No ray fired from inside a closed room should ever escape the map.
let escapes = 0;
const angleSamples = QUICK ? 16 : 180;
const points = [[1.5, 1.5], [4.5, 4.5], [2.5, 5.5]];
for (const [px, py] of points) {
for (let i = 0; i < angleSamples; i++) {
const a = (i / angleSamples) * Math.PI * 2;
const hit = castRay(map, px, py, Math.cos(a), Math.sin(a));
if (!hit) escapes++;
}
}
check('no ray escapes the closed test room', escapes === 0, `${escapes} escaped`);
// castColumns / makeCamera smoke test — every column should return a hit.
const cam = makeCamera(1.5, 1.5, 0, rules.fov);
const cols = castColumns(map, cam, 64);
check('castColumns returns one entry per column', cols.length === 64);
check('every column hits a wall', cols.every((c) => c !== null));
// Line of sight: clear along an open row, blocked through the internal wall.
check('LOS clear along open row', hasLineOfSight(map, 1.5, 1.5, 6.5, 1.5));
check('LOS blocked by internal wall', !hasLineOfSight(map, 1.5, 3.5, 6.5, 3.5));
}
// ---------------------------------------------------------------------------
section('3. Bullet swept-collision correctness');
// ---------------------------------------------------------------------------
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], enemies: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// A target far enough from the projectile's start AND end position that a
// point-sample test would miss it entirely, but the swept segment between
// them passes right through it.
{
const state = L.createState(level, rules);
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false, radius: rules.enemyById.guard.radius });
const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end
const vx = (endGuessX - startX) * rules.constants.tickHz;
state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
L.tick(state, rules);
check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`);
}
// A bullet fast enough to fully cross a 1-cell-thick wall within one tick —
// point-sampling only the tick's end position would see open floor on the
// far side and miss the wall entirely.
{
const W = 10, H = 3;
const row = new Array(W).fill(0);
row[4] = 1; // single-cell wall
const thinWallMap = { width: W, height: H, walls: [new Array(W).fill(1), row, new Array(W).fill(1)] };
const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules);
const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick
const vx = (endGuessX - startX) * rules.constants.tickHz;
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
const events = L.tick(state, rules);
const impact = events.find((e) => e.t === 'impact');
check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events));
check('impact lands at the wall face, not past it', impact && impact.x < 4.5 && impact.x >= 4, impact?.x);
check('projectile is removed after the wall hit', state.projectiles.length === 0);
}
// A clean miss must not falsely register a hit.
{
const state = L.createState(level, rules);
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false, radius: rules.enemyById.guard.radius });
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
L.tick(state, rules);
check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health);
check('a surviving projectile is kept, not detonated', state.projectiles.length === 1);
}
// Objects block movement but not bullets — the opposite of a wall, which
// blocks both. Checked on the same placed object so there's no ambiguity
// about which property is actually under test.
{
const objLevel = { ...level, objects: [{ x: 4, y: 1, frame: 0 }] };
const state = L.createState(objLevel, rules);
check('object cell is registered as movement-blocking', state.map.objectBlocked.has('4,1'));
L.setMoveIntent(state, 1, 0); // player starts at (1.5,1.5) facing east (angle 0), object's west face is at x=4
for (let i = 0; i < 300; i++) L.tick(state, rules);
check('player cannot walk through an object', state.player.x < 3.7, state.player.x);
L.setMoveIntent(state, 0, 0);
state.player.x = 1.5; state.player.y = 1.5; state.player.angle = 0;
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
let impact = null;
for (let i = 0; i < 200 && !impact; i++) {
const events = L.tick(state, rules);
impact = events.find((e) => e.t === 'impact');
}
check('bullets pass through an object instead of detonating on it', impact && impact.x > 6, impact);
}
}
// ---------------------------------------------------------------------------
section('4. Enemy AI');
// ---------------------------------------------------------------------------
{
const baseLevel = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// No line of sight (blocked by the row-3 internal wall) — should never alert.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 3.5, angle: 0 }, enemies: [{ type: 'guard', x: 6.5, y: 3.5, facing: 180 }] };
const state = L.createState(level, rules);
for (let i = 0; i < 300; i++) L.tick(state, rules);
check('guard with no line of sight stays idle', state.enemies[0].state === 'idle', state.enemies[0].state);
}
// Clear line of sight within detectRange — should alert within a bounded number of ticks.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
let alerted = false;
for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; }
check('guard with clear line of sight alerts promptly', alerted);
}
// Clear line of sight, but the player is behind the guard (facing 0 = east,
// player is to the west) — outside the 90 degree vision cone, so it should
// never alert no matter how long it stands there.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 0 }] };
const state = L.createState(level, rules);
for (let i = 0; i < 300; i++) L.tick(state, rules);
check('guard with player outside its vision cone stays idle', state.enemies[0].state === 'idle', state.enemies[0].state);
}
// Same idea, but the player sits just inside the +-45 degree cone edge
// (~44 degrees off the guard's heading, facing 180 = west) — should still
// alert. Kept away from the row-3 internal wall so LOS itself stays clear.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 6.4, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 4.5, facing: 180 }] };
const state = L.createState(level, rules);
let alerted = false;
for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; }
check('guard alerts to a player near the cone edge but still inside it', alerted);
}
// A stale 'attack' must clear the instant sight is lost — 'attack' is
// only ever SET (never otherwise cleared) in stepEnemyAI, so without the
// 2026-08-22 fix below it would keep reading (and rendering — see
// WolfensteinView._guardFacing's guardFrame.shoot) as attacking even
// after the enemy has gone back to just walking toward the player.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
let reachedAttack = false;
for (let i = 0; i < 20 && !reachedAttack; i++) { L.tick(state, rules); reachedAttack = state.enemies[0].state === 'attack'; }
check('guard within sight and fire range reaches attack state', reachedAttack, state.enemies[0].state);
// Attack state doesn't move the guard, so it's still exactly at its
// spawn (5.5,1.5) — teleport the player behind the row-3 wall (blocks
// hasLineOfSight from there, confirmed independent of this test) to
// cut sight abruptly, without needing several ticks of chase movement.
state.player.x = 1.5; state.player.y = 6.5;
L.tick(state, rules);
check('losing sight downgrades a stale "attack" back to "chase", not left stuck', state.enemies[0].state === 'chase', state.enemies[0].state);
}
// Patrol routes (stepPatrol): an idle guard with no player interference
// should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard
// and its whole route stay strictly west of the row-3 wall (x < 3), player
// stays strictly east of it (x = 6.5) — a straight line between them at
// y=3.5 always crosses the wall cells at x=3/4 regardless of where along
// its route the guard currently is, so this isolates pure patrol movement
// from AI detection (never alerts here, unlike the two tests above).
{
const level = {
...baseLevel, playerStart: { x: 6.5, y: 3.5, angle: 0 },
enemies: [{ type: 'guard', x: 1.5, y: 3.5, facing: 0, patrol: [{ x: 2.5, y: 3.5 }] }],
};
const state = L.createState(level, rules);
let minX = Infinity, maxX = -Infinity, everAlerted = false;
for (let i = 0; i < 2000; i++) {
L.tick(state, rules);
const e = state.enemies[0];
if (e.state !== 'idle') everAlerted = true;
minX = Math.min(minX, e.x); maxX = Math.max(maxX, e.x);
}
check('patrolling guard never alerts (LOS blocked its whole route)', !everAlerted);
check('a 1-waypoint route ping-pongs between home and the node', minX <= 1.6 && maxX >= 2.3, `x range [${minX.toFixed(2)}, ${maxX.toFixed(2)}]`);
}
// A route with 2+ authored waypoints closes into a one-way loop instead
// (home -> node1 -> node2 -> home -> ..., wrapping via
// (patrolIndex + 1) % path.length, never reversing — see stepPatrol's
// doc comment). Isolated from AI detection by distance alone here (a
// bigger, wall-free map, player far outside detectRange=8 the whole
// route) rather than the wall trick above, since the route touches more
// than one row/column this time.
{
const W = 14, H = 14;
const walls = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) row.push(x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0);
walls.push(row);
}
const level = {
width: W, height: H, cellSize: 64, walls, doors: [], items: [],
playerStart: { x: 12.5, y: 12.5, angle: 0 },
enemies: [{ type: 'guard', x: 1.5, y: 1.5, facing: 0, patrol: [{ x: 3.5, y: 1.5 }, { x: 1.5, y: 3.5 }] }],
exit: { x: 12.5, y: 1.5, radius: 0.6 },
};
const state = L.createState(level, rules);
const indices = [state.enemies[0].patrolIndex];
let everAlerted = false;
for (let i = 0; i < 3000; i++) {
L.tick(state, rules);
const e = state.enemies[0];
if (e.state !== 'idle') everAlerted = true;
if (e.patrolIndex !== indices[indices.length - 1]) indices.push(e.patrolIndex);
}
let cyclesForward = indices.length >= 6;
for (let i = 1; i < indices.length && cyclesForward; i++) {
if (indices[i] !== (indices[i - 1] + 1) % 3) cyclesForward = false;
}
check('a 2-waypoint route never alerts (player far outside detectRange)', !everAlerted);
check('a 2-waypoint route closes into a one-way loop, never reversing', cyclesForward, indices.slice(0, 9).join(','));
}
// Gunfire alerts every idle guard sharing the player's room
// (alertEnemiesInPlayerRoom/computeRooms), independent of range/LOS/cone
// — and independent of whether a connecting door is open or closed, since
// a door is always a room boundary for this purpose. No L.tick() calls
// anywhere in this block, so nothing but fireWeapon's new room-alert path
// can move a guard out of 'idle' here — a clean isolation from the
// ordinary spot-the-player AI in stepEnemyAI.
{
const W = 11, H = 5;
const walls = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
const border = x === 0 || y === 0 || x === W - 1 || y === H - 1;
const divider = x === 5 && (y === 1 || y === 3); // leaves (5,2) open for the door
row.push(border || divider ? 1 : 0);
}
walls.push(row);
}
const doors = [{ x: 5, y: 2, orientation: 'vertical' }];
const twoRoomLevel = (enemyX, enemyY) => ({
width: W, height: H, cellSize: 64, walls, doors, items: [],
playerStart: { x: 2.5, y: 2.5, angle: 0 },
enemies: [{ type: 'guard', x: enemyX, y: enemyY, facing: 180 }],
exit: { x: 9.5, y: 1.5, radius: 0.6 },
});
{
const state = L.createState(twoRoomLevel(3.5, 1.5), rules); // same room (left, x 1-4)
L.fireWeapon(state, rules);
check('gunshot alerts an idle guard sharing the room', state.enemies[0].state === 'alert', state.enemies[0].state);
}
{
const state = L.createState(twoRoomLevel(7.5, 1.5), rules); // other room (right, x 6-9), door closed
L.fireWeapon(state, rules);
check('gunshot does not alert a guard behind a closed door', state.enemies[0].state === 'idle', state.enemies[0].state);
}
{
const state = L.createState(twoRoomLevel(7.5, 1.5), rules); // same, door forced fully open
state.doors[0].slide = 1; state.doors[0].target = 1;
state.map.walls[2][5] = 0;
L.fireWeapon(state, rules);
check('gunshot does not alert a guard through an open door either', state.enemies[0].state === 'idle', state.enemies[0].state);
}
// Actually landing a hit alerts an enemy unconditionally, independent
// of BOTH other alert paths above: visual detection (LOS+FOV+range) and
// "heard gunfire in the room" (which the door-forced-open case just
// proved does NOT reach across a door, open or not). Guard sits on the
// same row as the door gap (y=2.5) so a bullet fired straight down that
// row travels through the open door and can actually connect, but
// facing east (away from the westward player) keeps it outside its own
// vision cone, and it's in the room-alert-blind "other room" the whole
// time — so alerting here can only be the direct-hit path itself.
{
const level = {
width: W, height: H, cellSize: 64, walls, doors, items: [],
playerStart: { x: 2.5, y: 2.5, angle: 0 },
enemies: [{ type: 'guard', x: 7.5, y: 2.5, facing: 0 }],
exit: { x: 9.5, y: 1.5, radius: 0.6 },
};
const state = L.createState(level, rules);
// Forcing the door open (unlike the fireWeapon-only sub-tests above,
// this one calls L.tick(), which runs stepDoors) also needs `timer`
// set — left at its createState default of 0, stepDoors' auto-close
// countdown underflows on the very first tick and starts sliding the
// door shut again right under the in-flight bullet.
state.doors[0].slide = 1; state.doors[0].target = 1; state.doors[0].timer = rules.constants.doorAutoCloseMs;
state.map.walls[2][5] = 0;
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
L.tick(state, rules); // fires — projectile now in flight through the open door
let alerted = false, healthDropped = false;
for (let i = 0; i < 60 && !alerted; i++) {
L.setFireHeld(state, false); // one shot only — a second room-alert-free shot isn't the point here
L.tick(state, rules);
if (state.enemies[0].health < rules.enemyById.guard.health) healthDropped = true;
alerted = state.enemies[0].state !== 'idle';
}
check('the shot actually connected (sanity check for the assertion below)', healthDropped);
check('being shot alerts a guard even with room-alert and vision-cone both blind to it', alerted, state.enemies[0].state);
}
}
// A dead guard stops acting and can't be hit/killed twice. The pistol is
// semi-automatic now, so holding fireHeld continuously only fires once —
// toggle it every tick to simulate repeated trigger pulls, same as a
// player clicking as fast as the cooldown allows.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'pistol');
let deaths = 0;
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
L.setFireHeld(state, i % 2 === 0);
const events = L.tick(state, rules);
deaths += events.filter((e) => e.t === 'enemyDied').length;
}
check('guard actually dies within the simulated window', state.enemies[0].dead, `health=${state.enemies[0].health}`);
check('enemyDied fires exactly once', deaths === 1, `fired ${deaths} times`);
const healthAtDeath = state.enemies[0].health;
for (let i = 0; i < 60; i++) L.tick(state, rules);
check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead');
}
// Semi-automatic pistol: holding the trigger continuously through a
// cooldown expiry must NOT refire — only a fresh press (a release then a
// new hold) does.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
let fires = 0;
const ticksPerShot = Math.ceil(rules.weaponById.pistol.cooldownMs / rules.stepMs);
for (let i = 0; i < ticksPerShot * 3; i++) {
const events = L.tick(state, rules);
fires += events.filter((e) => e.t === 'weaponFired').length;
}
check('holding the trigger through a semi-auto weapon\'s cooldown fires only once', fires === 1, `fired ${fires} times`);
L.setFireHeld(state, false);
L.tick(state, rules);
L.setFireHeld(state, true);
const events = L.tick(state, rules);
fires += events.filter((e) => e.t === 'weaponFired').length;
check('a fresh release-then-press fires again', fires === 2, `fired ${fires} times total`);
}
// A non-lethal ranged (ammo) hit stuns the guard for its type's stunMs,
// freezing it in place, and it resumes normal behavior once stunMs clears.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
state.enemies[0].health = 1000; // survives many hits so it can't die mid-check
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
L.tick(state, rules); // fires — the projectile is now in flight
let stunnedAt = null;
for (let i = 0; i < 30 && stunnedAt === null; i++) {
L.setFireHeld(state, false); // don't keep firing while we watch the stun window
L.tick(state, rules);
if (state.enemies[0].stunMs > 0) stunnedAt = i;
}
check('a non-lethal ranged hit stuns the guard', stunnedAt !== null && state.enemies[0].stunMs <= rules.enemyById.guard.stunMs, state.enemies[0].stunMs);
const frozenX = state.enemies[0].x, frozenY = state.enemies[0].y, frozenState = state.enemies[0].state;
let movedWhileStunned = false;
while (state.enemies[0].stunMs > 0) {
L.tick(state, rules);
if (state.enemies[0].stunMs > 0 && (state.enemies[0].x !== frozenX || state.enemies[0].y !== frozenY)) movedWhileStunned = true;
}
check('a stunned guard does not move while stunMs > 0', !movedWhileStunned);
let resumed = false;
for (let i = 0; i < 30 && !resumed; i++) {
L.tick(state, rules);
resumed = state.enemies[0].state !== 'idle';
}
check('the guard resumes normal behavior once the stun clears', resumed, frozenState);
}
// A melee (fists) hit damages but does NOT stun — stun is scoped to ammo
// (ranged) hits only, per fireWeapon's melee branch.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'fists');
const healthBefore = state.enemies[0].health;
L.fireWeapon(state, rules);
check('a melee hit damages the guard', state.enemies[0].health < healthBefore, state.enemies[0].health);
check('a melee hit does not stun the guard', state.enemies[0].stunMs === 0, state.enemies[0].stunMs);
}
// Ammo-type damage rolls stay within [damageMin, damageMax], and both
// bounds actually get hit across enough trials (catches an off-by-one in
// rollDamage's inclusive-range formula).
{
const at = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'pistol');
let minSeen = Infinity, maxSeen = -Infinity, outOfRange = 0;
const trials = QUICK ? 100 : 500;
for (let i = 0; i < trials; i++) {
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
state.player.ammo[rules.weaponById.pistol.ammoType] = 1; state.player.cooldowns.pistol = 0;
const before = state.enemies[0].health;
L.fireWeapon(state, rules);
// fireWeapon only spawns the projectile; step it forward to resolve the hit.
for (let t = 0; t < 30 && state.enemies[0].health === before; t++) L.tick(state, rules);
const dmg = before - state.enemies[0].health;
if (dmg < at.damageMin || dmg > at.damageMax) outOfRange++;
minSeen = Math.min(minSeen, dmg); maxSeen = Math.max(maxSeen, dmg);
}
check('every rolled damage falls within the ammo type\'s range', outOfRange === 0, `${outOfRange}/${trials} out of range`);
check('the roll reaches both the low and high end of the range', minSeen === at.damageMin && maxSeen === at.damageMax, `saw [${minSeen}, ${maxSeen}], expected [${at.damageMin}, ${at.damageMax}]`);
}
// "Guards carry pistols": a guard's own fired projectile references the
// real pistol weapon's ammo type and refires on the pistol's cooldown, not
// a separate hardcoded stat block.
{
const level = { ...baseLevel, playerStart: { x: 6.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 1.5, y: 1.5, facing: 0 }] };
const state = L.createState(level, rules);
let firstProjTick = null, secondProjTick = null;
for (let i = 0; i < 300 && secondProjTick === null; i++) {
const before = state.projectiles.length;
L.tick(state, rules);
if (state.projectiles.length > before) {
if (firstProjTick === null) firstProjTick = i; else secondProjTick = i;
}
}
check('a guard\'s fired projectile carries the pistol\'s ammo type', state.projectiles.at(-1)?.ammoType === rules.weaponById.pistol.ammoType, state.projectiles.at(-1)?.ammoType);
const expectedTicks = Math.round(rules.weaponById.pistol.cooldownMs / rules.stepMs);
check('consecutive guard shots are spaced by the pistol\'s cooldown', firstProjTick !== null && secondProjTick !== null && near(secondProjTick - firstProjTick, expectedTicks, 2), `${secondProjTick - firstProjTick} ticks, expected ~${expectedTicks}`);
}
}
// ---------------------------------------------------------------------------
section('4b. Guard ammo drops');
// ---------------------------------------------------------------------------
{
const baseLevel = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// Fresh one-guard state, guaranteed to die on the first fists swing
// (health forced to 1, fists deal 15) — player is close enough (0.7
// units) and squarely ahead to be within fists' 0.9 range and 100 degree
// arc regardless of the random drop roll itself.
const killGuard = () => {
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'fists');
state.enemies[0].health = 1;
L.fireWeapon(state, rules);
return state;
};
// Drop shape/correctness — loop until a drop actually happens (33% per
// kill; the odds of none landing in 60 independent tries are ~7e-10, so
// this isn't meaningfully flaky) and check exactly what landed.
{
let state = null;
for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard();
check('a killed guard eventually drops a pickup (33% chance per kill)', state.pickups.length === 1, state.pickups.length);
const drop = state.pickups[0];
check('the drop is an ammo-clip', drop?.itemId === 'ammo-clip', drop?.itemId);
check('the drop sits exactly at the guard\'s death position', drop && near(drop.x, 2.2) && near(drop.y, 1.5), drop);
check('the drop starts untaken', drop?.taken === false);
}
// Rate check over many independent kills — a wide tolerance band (well
// over 4 standard errors either side of 0.33 at n=2000) so this only
// fails on a genuinely broken chance, never on ordinary variance.
{
const n = 2000;
let drops = 0;
for (let i = 0; i < n; i++) if (killGuard().pickups.length > 0) drops++;
const rate = drops / n;
check(`observed drop rate is close to 33% over ${n} kills`, rate > 0.28 && rate < 0.38, rate.toFixed(3));
}
// A guard that survives a hit never drops anything.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'fists');
L.fireWeapon(state, rules); // 15 damage vs. the guard's full 20 health — survives
check('a guard that survives a hit does not drop anything', state.pickups.length === 0, state.pickups.length);
check('the surviving guard is not marked dead', !state.enemies[0].dead && state.enemies[0].health === 5, state.enemies[0].health);
}
// Id collision-avoidance: repeatedly "kill" the same guard within ONE
// state (resetting health/dead between swings) alongside a
// level-authored pickup that already holds id 0 — every drop's id must
// be unique and never reuse that 0.
{
const level = {
...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 },
items: [{ type: 'health', x: 3.5, y: 3.5 }],
enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }],
};
const state = L.createState(level, rules);
check('the authored pickup holds id 0; nextPickupId starts right after it', state.pickups[0].id === 0 && state.nextPickupId === 1, state.nextPickupId);
L.switchWeapon(state, 'fists');
const N = 40; // ~13 expected drops at 33% each — P(fewer than 2) is astronomically small
for (let i = 0; i < N; i++) {
state.enemies[0].dead = false; state.enemies[0].health = 1;
L.fireWeapon(state, rules);
}
const dropIds = state.pickups.filter((p) => p.itemId === 'ammo-clip').map((p) => p.id);
check(`at least a couple of ${N} repeated kills dropped something`, dropIds.length >= 2, dropIds.length);
check('every drop got its own unique id, none colliding with the authored pickup\'s id 0', new Set(dropIds).size === dropIds.length && !dropIds.includes(0), dropIds);
}
// Save/load round-trip: nextPickupId persists (so a drop after loading
// doesn't collide with an id already handed out before saving), and a
// save predating this feature degrades to pickups.length, not a crash.
{
let state = null;
for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard();
const restored = L.deserialize(rules, L.serialize(state));
check('nextPickupId round-trips through save/load', restored && restored.nextPickupId === state.nextPickupId, restored?.nextPickupId);
const legacy = JSON.parse(L.serialize(state));
delete legacy.nextPickupId;
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
check('a save predating ammo drops degrades nextPickupId to pickups.length, not a crash', restoredLegacy && restoredLegacy.nextPickupId === restoredLegacy.pickups.length, restoredLegacy?.nextPickupId);
}
}
// ---------------------------------------------------------------------------
section('5. Save/load round-trip');
// ---------------------------------------------------------------------------
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls,
playerStart: { x: 1.5, y: 1.5, angle: 0.3 },
doors: [{ x: 2, y: 5, orientation: 'vertical' }],
enemies: [{ type: 'guard', x: 5.5, y: 5.5, facing: 90 }],
items: [{ type: 'health', x: 4.5, y: 4.5 }],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
const state = L.createState(level, rules);
L.setMoveIntent(state, 1, 0.3);
for (let i = 0; i < 30; i++) L.tick(state, rules);
const raw = L.serialize(state);
const restored = L.deserialize(rules, raw);
check('deserialize succeeds', !!restored);
check('player position round-trips', restored && near(restored.player.x, state.player.x) && near(restored.player.y, state.player.y));
check('player health round-trips', restored && restored.player.health === state.player.health);
check('enemy state round-trips', restored && restored.enemies[0].x === state.enemies[0].x && restored.enemies[0].health === state.enemies[0].health);
check('wall grid RLE round-trips byte-identical', restored && JSON.stringify(restored.map.walls) === JSON.stringify(state.map.walls));
check('tick count round-trips', restored && restored.tick === state.tick);
const badVersion = JSON.stringify({ ...JSON.parse(raw), v: 999 });
check('a save stamped with a future version is rejected, not thrown', L.deserialize(rules, badVersion) === null);
check('garbage input is rejected, not thrown', L.deserialize(rules, '{not json') === null);
}
// ---------------------------------------------------------------------------
section('5b. Campaign carry-over (createState\'s carry param)');
// ---------------------------------------------------------------------------
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], enemies: [], items: [],
playerStart: { x: 1.5, y: 1.5, angle: 0 },
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// No carry at all (every pre-existing caller — tests, Test Play, the
// ASCII-map tool) must reproduce the exact original default: fists+pistol
// owned, pistol equipped and carrying its own startAmmo, full health.
{
const state = L.createState(level, rules);
check('no carry: owns exactly fists+pistol', JSON.stringify(state.player.weapons) === JSON.stringify(['fists', 'pistol']), state.player.weapons);
check('no carry: pistol equipped with its startAmmo granted', state.player.weapon === 'pistol' && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo, state.player.ammo);
check('no carry: full health', state.player.health === rules.constants.playerMaxHealth, state.player.health);
}
// A fresh-campaign carry (WolfensteinGame._freshCampaignCarry's shape) —
// fists only, no ammo at all, not even pistol's usual startAmmo bonus
// (there's no pistol to grant it to).
{
const carry = { weapons: ['fists'], weapon: 'fists', ammo: {}, health: rules.constants.playerMaxHealth };
const state = L.createState(level, rules, carry);
check('fresh-campaign carry: owns only fists', JSON.stringify(state.player.weapons) === JSON.stringify(['fists']), state.player.weapons);
check('fresh-campaign carry: fists equipped', state.player.weapon === 'fists');
check('fresh-campaign carry: no ammo of any type', Object.values(state.player.ammo).every((v) => v === 0), state.player.ammo);
}
// A mid-progress carry (WolfensteinGame._extractCarry's shape, as if
// pulled from a just-won mission) restores weapons/weapon/ammo/health
// EXACTLY as given — no implicit startAmmo bonus layered on top (these
// weapons weren't "just granted" by this level).
{
const carry = { weapons: ['fists', 'pistol', 'shotgun'], weapon: 'shotgun', ammo: { '9mm': 3, shells: 7, plasma: 0 }, health: 62 };
const state = L.createState(level, rules, carry);
check('mid-progress carry: weapons list restored exactly', JSON.stringify(state.player.weapons) === JSON.stringify(carry.weapons), state.player.weapons);
check('mid-progress carry: equipped weapon restored', state.player.weapon === 'shotgun');
check('mid-progress carry: ammo restored exactly, no startAmmo bonus added on top', state.player.ammo['9mm'] === 3 && state.player.ammo.shells === 7, state.player.ammo);
check('mid-progress carry: health restored exactly (not topped up to full)', state.player.health === 62, state.player.health);
}
// Keys never carry over, regardless — createState hardcodes player.keys
// to [] unconditionally, since `carry` has no keys field in its shape at
// all (see WolfensteinLogic.createState's own doc comment).
{
const carry = { weapons: ['fists', 'pistol'], weapon: 'pistol', ammo: { '9mm': 8 }, health: 100 };
const state = L.createState(level, rules, carry);
check('carry never restores keys, even a fully-loaded one', state.player.keys.length === 0, state.player.keys);
}
}
// ---------------------------------------------------------------------------
section('6. Weapon variety & pickups');
// ---------------------------------------------------------------------------
{
const baseLevel = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// Machine gun (3 shots/sec) and gatling gun (6 shots/sec) are automatic —
// holding the trigger should refire every cooldown, spaced by cooldownMs,
// same shape as the existing guard-cadence check in section 4 but for the
// player's own automatic weapons.
for (const wid of ['machinegun', 'gatling']) {
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
state.player.weapons.push(wid);
L.switchWeapon(state, wid);
L.setFireHeld(state, true);
const w = rules.weaponById[wid];
const ticksPerShot = Math.ceil(w.cooldownMs / rules.stepMs);
const shotTicks = [];
for (let i = 0; i < ticksPerShot * 4 + 2; i++) {
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) shotTicks.push(i);
}
check(`${wid} fires repeatedly while the trigger is held`, shotTicks.length >= 4, `fired ${shotTicks.length} times`);
const gaps = shotTicks.slice(1).map((t, i) => t - shotTicks[i]);
// Matches the engine's own clearing formula (stepPlayer decrements
// cooldown once per tick, fires once it's <=0): ceil, not round — a
// cooldown that's a non-exact multiple of stepMs (e.g. gatling's 167ms
// over a 16.667ms step) needs one extra tick to actually clear.
const expectedGap = Math.ceil(w.cooldownMs / rules.stepMs);
check(`${wid}'s automatic fire is spaced by its own cooldown, not another weapon's`, gaps.every((g) => near(g, expectedGap, 1)), `gaps=${gaps.join(',')}, expected ~${expectedGap}`);
}
// Shotgun: single shot per trigger pull (semi-auto), 1.2s cooldown, and a
// damage roll 5x the pistol's (already checked structurally in section 1;
// this exercises the actual fired projectile).
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
state.player.weapons.push('shotgun');
state.player.ammo.shells = 10;
L.switchWeapon(state, 'shotgun');
check('shotgun starts with a 1.2s cooldown', rules.weaponById.shotgun.cooldownMs === 1200);
L.setFireHeld(state, true);
const before = state.enemies[0].health;
L.tick(state, rules);
let dmg = 0;
for (let t = 0; t < 30 && dmg === 0; t++) { L.tick(state, rules); dmg = before - state.enemies[0].health; }
const shells = rules.ammoTypeById.shells;
check('a shotgun hit rolls within the shells ammo type\'s range', dmg >= shells.damageMin && dmg <= shells.damageMax, dmg);
// Reset to a known-full cooldown (the hit-resolution loop above already
// burned some of the original 1.2s waiting for the swept hit to
// register) and only watch a window comfortably short of that full
// cooldown — this is testing "no EARLY refire," not "never refires at
// all," so the window must end before the cooldown would legitimately
// clear on its own.
state.player.cooldowns.shotgun = rules.weaponById.shotgun.cooldownMs;
let refired = false;
const watchTicks = Math.ceil(rules.weaponById.shotgun.cooldownMs / rules.stepMs) - 5;
for (let i = 0; i < watchTicks; i++) {
L.setFireHeld(state, i % 2 === 0); // toggle every tick to simulate rapid, distinct trigger presses
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) refired = true;
}
check('shotgun does not refire before its cooldown clears even with the trigger repeatedly pressed', !refired);
}
// Plasma rifle: one trigger pull fires a full 8-shot burst, spaced by
// burstIntervalMs, and the burst keeps going even if the trigger is
// released partway through (it's the burst that's semi-automatic, not
// each shot).
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
state.player.weapons.push('plasmarifle');
state.player.ammo.plasma = 100;
L.switchWeapon(state, 'plasmarifle');
L.setFireHeld(state, true);
const shotTicks = [];
for (let i = 0; i < 400 && shotTicks.length < 8; i++) {
if (i === 1) L.setFireHeld(state, false); // release right after the first shot — burst should continue anyway
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) shotTicks.push(i);
}
check('a single plasma rifle burst fires exactly 8 shots', shotTicks.length === 8, `fired ${shotTicks.length} times`);
const gaps = shotTicks.slice(1).map((t, i) => t - shotTicks[i]);
const expectedGap = Math.ceil(rules.weaponById.plasmarifle.burstIntervalMs / rules.stepMs); // see the automatic-weapons test above for why ceil, not round
check('plasma rifle burst shots are spaced by burstIntervalMs', gaps.every((g) => near(g, expectedGap, 1)), `gaps=${gaps.join(',')}`);
check('plasma rifle consumed 8 plasma cells for the burst', state.player.ammo.plasma === 92, state.player.ammo.plasma);
// A second trigger pull starts a new burst — but only once BOTH the
// trigger has a fresh press edge AND the post-burst cooldown has
// cleared; holding continuously through that boundary does not
// auto-chain (matches semi-auto semantics: the burst, not each shot,
// needs re-triggering), so this waits out the cooldown with the
// trigger released before pressing again.
L.setFireHeld(state, false);
for (let i = 0; i < 10; i++) L.tick(state, rules);
L.setFireHeld(state, true);
let secondBurstStarted = false;
for (let i = 0; i < 10 && !secondBurstStarted; i++) {
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) secondBurstStarted = true;
}
check('a fresh trigger pull starts a new burst after the previous one finished', secondBurstStarted);
}
// A burst that runs out of ammo mid-way stops cleanly (no infinite
// weaponEmpty spam, no stuck burstRemaining preventing the next weapon).
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
state.player.weapons.push('plasmarifle');
state.player.ammo.plasma = 3; // fewer than one burst's 8 shots
L.switchWeapon(state, 'plasmarifle');
L.setFireHeld(state, true);
let fired = 0, emptyEvents = 0;
for (let i = 0; i < 200; i++) {
const events = L.tick(state, rules);
fired += events.filter((e) => e.t === 'weaponFired').length;
emptyEvents += events.filter((e) => e.t === 'weaponEmpty').length;
}
check('a burst that runs out of ammo mid-way fires only as many shots as it had ammo for', fired === 3, fired);
check('running out of ammo mid-burst does not spam weaponEmpty forever', emptyEvents < 20, emptyEvents);
check('burstRemaining is cleared once the burst is cut short by empty ammo', state.player.burstRemaining === 0, state.player.burstRemaining);
}
// Ammo pooling: pistol, machine gun and gatling gun all share the '9mm'
// pool — firing one weapon spends ammo the others can also use, and a
// "bullet ammo" pickup (whichever weapon is equipped) refills the shared
// pool rather than a per-weapon one.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'ammo-clip', x: 3.5, y: 1.5 }] };
const state = L.createState(level, rules);
state.player.weapons.push('machinegun');
L.switchWeapon(state, 'pistol');
const startAmmo = state.player.ammo['9mm'];
L.setFireHeld(state, true);
L.tick(state, rules); // one pistol shot
check('firing the pistol spends from the shared 9mm pool', state.player.ammo['9mm'] === startAmmo - 1, state.player.ammo['9mm']);
L.switchWeapon(state, 'machinegun');
const beforeMg = state.player.ammo['9mm'];
L.setFireHeld(state, false);
L.tick(state, rules);
L.setFireHeld(state, true);
L.tick(state, rules); // one machine gun shot
check('the machine gun draws from the same 9mm pool the pistol just spent from', state.player.ammo['9mm'] === beforeMg - 1, state.player.ammo['9mm']);
// Machine gun is automatic — stop holding the trigger before walking to
// the pickup, or it keeps firing (and spending ammo) the whole way there.
L.setFireHeld(state, false);
L.setMoveIntent(state, 1, 0);
const beforePickup = state.player.ammo['9mm'];
for (let i = 0; i < 60 && state.player.ammo['9mm'] === beforePickup; i++) L.tick(state, rules);
check('an ammo-clip pickup (item.ammoType 9mm) credits the shared pool', state.player.ammo['9mm'] === beforePickup + 10, state.player.ammo['9mm']);
}
// A weapon pickup grants the weapon, adds its ammo bonus to the correct
// ammo-type pool (not a per-weapon bucket), and auto-equips it.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'shotgun', x: 3.5, y: 1.5 }] };
const state = L.createState(level, rules);
check('shotgun starts unowned', !state.player.weapons.includes('shotgun'));
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60 && !state.player.weapons.includes('shotgun'); i++) L.tick(state, rules);
check('walking over a shotgun pickup grants it', state.player.weapons.includes('shotgun'));
check('picking up a weapon auto-equips it', state.player.weapon === 'shotgun');
check('the shotgun pickup credited the shells pool with its ammo bonus', state.player.ammo.shells === rules.itemById.shotgun.ammo, state.player.ammo.shells);
}
// Small/large medpacks heal for their tuned amounts, capped at max health.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'health-large', x: 3.5, y: 1.5 }] };
const state = L.createState(level, rules);
state.player.health = 40;
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60 && state.player.health === 40; i++) L.tick(state, rules);
check('a large medpack heals for its tuned amount (50)', state.player.health === 90, state.player.health);
}
// Mouse-wheel cycling (cycleWeapon): steps through rules.weapons' own
// order — the same order the 1-6 keys map to — skipping anything not yet
// owned, wrapping past either end.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
check('starts on pistol (createState default), owning only fists+pistol', state.player.weapon === 'pistol' && state.player.weapons.length === 2, JSON.stringify(state.player.weapons));
// Forward from pistol has nothing else owned between it and fists going
// either way around the loop — skips shotgun/machinegun/gatling/
// plasmarifle entirely and wraps straight to fists.
L.cycleWeapon(state, rules, 1);
check('cycling forward with only fists+pistol owned wraps around to fists', state.player.weapon === 'fists', state.player.weapon);
L.cycleWeapon(state, rules, 1);
check('cycling forward again goes back to pistol (the only other owned weapon)', state.player.weapon === 'pistol', state.player.weapon);
L.cycleWeapon(state, rules, -1);
check('cycling backward from pistol goes to fists directly, no wrap needed', state.player.weapon === 'fists', state.player.weapon);
// Owning machinegun (but not shotgun) in between: forward from pistol
// must skip the unowned shotgun and land on machinegun.
state.player.weapons.push('machinegun');
L.switchWeapon(state, 'pistol');
L.cycleWeapon(state, rules, 1);
check('cycling forward skips an unowned weapon in between (shotgun)', state.player.weapon === 'machinegun', state.player.weapon);
L.cycleWeapon(state, rules, -1);
check('cycling backward from there returns to pistol, skipping shotgun again', state.player.weapon === 'pistol', state.player.weapon);
// Owning every weapon: six forward cycles from fists visits each exactly
// once, in rules.weapons' own order, and the seventh returns to fists.
for (const w of rules.weapons) if (!state.player.weapons.includes(w.id)) state.player.weapons.push(w.id);
L.switchWeapon(state, 'fists');
const visited = [state.player.weapon];
for (let i = 0; i < 6; i++) { L.cycleWeapon(state, rules, 1); visited.push(state.player.weapon); }
check('with every weapon owned, 6 forward cycles visit each once and the 7th wraps back to fists', JSON.stringify(visited) === JSON.stringify(rules.weapons.map((w) => w.id).concat('fists')), visited.join(','));
}
}
// ---------------------------------------------------------------------------
section('6b. Colored keys and locked doors');
// ---------------------------------------------------------------------------
{
// Picking up a key adds its color to player.keys, not weapons/ammo, and
// doesn't duplicate on a second overlap of an already-taken pickup.
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], enemies: [],
playerStart: { x: 1.5, y: 1.5, angle: 0 },
items: [{ type: 'key-blue', x: 3.5, y: 1.5 }],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
const state = L.createState(level, rules);
check('player starts a level holding no keys', state.player.keys.length === 0, state.player.keys);
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60 && !state.player.keys.includes('blue'); i++) L.tick(state, rules);
check('walking over a blue key pickup grants it', state.player.keys.includes('blue'), state.player.keys);
check('a key pickup does not touch weapons or ammo', state.player.weapons.length === 2 && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo);
for (let i = 0; i < 10; i++) L.tick(state, rules);
check('a key is not duplicated once already held', state.player.keys.filter((c) => c === 'blue').length === 1, state.player.keys);
}
// openNearestDoor: a colored door refuses to open without the matching
// key (emitting doorLocked instead of doorOpen), then opens normally once
// the key is granted.
{
const W = 8, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
const level = {
id: 'test', name: 'Test', width: W, height: H, cellSize: 64,
walls, enemies: [], items: [],
playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [{ x: 3, y: 1, orientation: 'vertical', color: 'red' }],
exit: { x: 6.5, y: 1.5, radius: 0.6 },
};
const state = L.createState(level, rules);
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 40; i++) L.tick(state, rules); // walk up to the door
L.openNearestDoor(state);
check('a locked door does not open without the matching key', state.doors[0].target === 0, state.doors[0]);
check('attempting a locked door emits doorLocked with its color', state.events.some((e) => e.t === 'doorLocked' && e.color === 'red'), JSON.stringify(state.events));
check('nearestDoorInfo reports the nearby door as locked', L.nearestDoorInfo(state)?.locked === true, JSON.stringify(L.nearestDoorInfo(state)));
state.player.keys.push('red');
L.openNearestDoor(state);
check('the same door opens once the matching key is held', state.doors[0].target === 1, state.doors[0]);
check('nearestDoorInfo no longer offers an already-opening door', L.nearestDoorInfo(state) === null, JSON.stringify(L.nearestDoorInfo(state)));
}
// Enemy AI push-through (stepDoors' enemyNearDoor branch) is excluded for
// colored doors — guards never carry keys, so a locked door stays a hard
// barrier to them the same way it does to the player without the key.
{
const W = 6, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
const doorLevel = (color) => ({
id: 'test', name: 'Test', width: W, height: H, cellSize: 64,
walls, items: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [{ x: 3, y: 1, orientation: 'vertical', color }],
enemies: [{ type: 'guard', x: 2.7, y: 1.5, facing: 0 }], // within DOOR_RADIUS of the door, not inside it
exit: { x: 4.5, y: 1.5, radius: 0.6 },
});
{
const state = L.createState(doorLevel(null), rules);
L.tick(state, rules);
check('a guard standing near a normal door pushes it open (no color = no key needed)', state.doors[0].target === 1, state.doors[0]);
}
{
const state = L.createState(doorLevel('yellow'), rules);
L.tick(state, rules);
check('a guard standing near a locked door does not push it open', state.doors[0].target === 0, state.doors[0]);
}
}
// Key/door round-trip through save/load — SAVE_VERSION was not bumped for
// this feature, so a pre-feature save (no `keys` field on player) must
// still deserialize cleanly, defaulting to an empty keyring.
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [{ x: 2, y: 5, orientation: 'vertical', color: 'blue' }],
enemies: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
const state = L.createState(level, rules);
state.player.keys.push('blue');
const restored = L.deserialize(rules, L.serialize(state));
check('a held key round-trips through save/load', restored && restored.player.keys.includes('blue'), restored?.player.keys);
check('a door\'s color round-trips through save/load', restored && restored.doors[0].color === 'blue', restored?.doors[0]);
const legacy = JSON.parse(L.serialize(state));
delete legacy.player.keys;
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
check('a save predating keys deserializes with an empty keyring, not a crash', restoredLegacy && Array.isArray(restoredLegacy.player.keys) && restoredLegacy.player.keys.length === 0, restoredLegacy?.player.keys);
}
// validateLevel/bfsReachable is key-aware: a colored door only counts as
// passable once its matching key is reachable from playerStart WITHOUT
// needing that door — a key placed behind its own door (or a locked door
// with no key anywhere) makes the exit unreachable, exactly like a level
// with no path at all.
{
const W = 7, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
const base = {
id: 'test', name: 'Test', width: W, height: H, cellSize: 64, walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [],
exit: { x: 5.5, y: 1.5, radius: 0.6 },
};
const behindLevel = { ...base, doors: [{ x: 3, y: 1, color: 'blue' }], items: [{ type: 'key-blue', x: 4.5, y: 1.5 }] };
check('a key placed behind its own locked door makes the exit unreachable', !L.validateLevel(L.buildLevelModel(behindLevel)).reachable);
const beforeLevel = { ...base, doors: [{ x: 4, y: 1, color: 'blue' }], items: [{ type: 'key-blue', x: 2.5, y: 1.5 }] };
check('a key placed before its locked door keeps the exit reachable', L.validateLevel(L.buildLevelModel(beforeLevel)).reachable);
const noKeyLevel = { ...base, doors: [{ x: 3, y: 1, color: 'red' }], items: [] };
check('a locked door with its key nowhere in the level makes the exit unreachable', !L.validateLevel(L.buildLevelModel(noKeyLevel)).reachable);
const unknownColorLevel = { ...base, doors: [{ x: 3, y: 1, color: 'purple' }], items: [] };
const unknownResult = L.validateLevel(L.buildLevelModel(unknownColorLevel));
check('an unrecognized door color is flagged as an authoring issue', unknownResult.issues.some((i) => i.includes('unknown color')), unknownResult.issues);
}
}
// ---------------------------------------------------------------------------
section('6c. Secret doors');
// ---------------------------------------------------------------------------
{
// Raw raycaster geometry: a moving-wall slab against hand-computed
// distances, the same spirit as section 2's hand-computed wall hits — the
// sliding-wall math (checkSecretDoorCell, exercised via castRay's
// secretDoors param) is new geometry, not just engine plumbing, so it
// deserves its own direct check independent of the higher-level sim.
{
const W = 10, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
walls[1][3] = 5; // origin cell, kept at its painted wall type (never bulldozed)
const map = { width: W, height: H, walls };
const sdBase = { x: 3, y: 1, axis: 'x', sign: 1, restDist: 4, wallType: 5 };
const atRest = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 0 })]);
check('closed (progress 0) hits the origin cell\'s own near face, same as a plain wall would', atRest && near(atRest.perpDist, 2.5), atRest?.perpDist);
const midSlide = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 1.5 })]);
check('mid-slide, the block face has advanced exactly `progress` cells', midSlide && near(midSlide.perpDist, 4) && midSlide.mapX === 4, JSON.stringify(midSlide));
const fullyOpen = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 4 })]);
check('at progress === restDist, the block sits exactly at the rest cell (origin + restDist)', fullyOpen && near(fullyOpen.perpDist, 6.5) && fullyOpen.mapX === 7, JSON.stringify(fullyOpen));
const fromBehind = castRay(map, 9.5, 1.5, -1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 2 })]);
check('a ray approaching from the far side hits the block\'s far (trailing) face, not its near one', fromBehind && near(fromBehind.perpDist, 3.5) && fromBehind.mapX === 5, JSON.stringify(fromBehind));
const wrongRow = castRay(map, 0.5, 2.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 2 })]);
check('a ray in a different row is unaffected — falls through to the ordinary static wall check', wrongRow && wrongRow.mapY === 2 && wrongRow.wallType === 1, JSON.stringify(wrongRow));
}
// Two walkable rows (y=1, the secret door's own row, and y=2, a plain
// open alternate route around it) so "is the secret optional or does it
// gate the only path" can actually be tested both ways — a single-row
// corridor would make every secret door mandatory by construction.
const secretLevel = (dir, extra) => {
const W = 8, H = 4;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
walls[1][3] = 1; // the secret door's own wall cell
return {
id: 'test', name: 'Test', width: W, height: H, cellSize: 64, walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [],
exit: { x: 6.5, y: 1.5, radius: 0.6 },
secretDoors: [{ x: 3, y: 1, dir }],
...extra,
};
};
// createState: restDist scans against the level's own static walls (open
// cells 4,5,6 before hitting the type-1 border at x=7 -> 3 open cells),
// and the origin cell keeps its authored wall type untouched (unlike a
// normal door, never baked to a special type).
{
const level = secretLevel(0); // East
const state = L.createState(level, rules);
const sd = state.secretDoors[0];
check('secret door axis/sign are derived correctly from its authored direction (East)', sd.axis === 'x' && sd.sign === 1, JSON.stringify(sd));
check('restDist is scanned against the level\'s own walls (3 open cells before the border)', sd.restDist === 3, sd.restDist);
check('the origin cell keeps its authored wall type in the live map (looks like a plain wall)', state.map.walls[1][3] === 1, state.map.walls[1][3]);
check('a secret door starts closed with zero progress', sd.state === 'closed' && sd.progress === 0);
}
// Trigger: only within range, only a 'closed' door, fires secretFound,
// and does nothing when nothing is in range.
{
const level = secretLevel(0);
const state = L.createState(level, rules);
L.triggerNearestSecretDoor(state); // player starts at (1.5,1.5), well outside DOOR_RADIUS of (3.5,1.5)
check('triggering with nothing in range is a silent no-op', state.secretDoors[0].state === 'closed');
state.player.x = 3.0; state.player.y = 1.5; // now within range of the origin cell's center
state.events = [];
L.triggerNearestSecretDoor(state);
check('triggering in range starts the slide', state.secretDoors[0].state === 'sliding');
check('triggering pushes a secretFound event', state.events.some((e) => e.t === 'secretFound'));
state.events = [];
L.triggerNearestSecretDoor(state);
check('an already-triggered door does not re-trigger or re-fire the event', state.events.length === 0);
}
// stepSecretDoors: progress advances at SECRET_DOOR_MS_PER_CELL per cell,
// collision stays sealed until it's fully done, then the map flips
// atomically (origin open, rest cell solid with the SAME wall type).
{
const level = secretLevel(0);
const state = L.createState(level, rules);
state.player.x = 3.0; state.player.y = 1.5;
L.triggerNearestSecretDoor(state);
for (let i = 0; i < 10; i++) L.tick(state, rules);
const sd = state.secretDoors[0];
check('progress advances while sliding, but hasn\'t reached restDist yet after a few ticks', sd.progress > 0 && sd.progress < sd.restDist, sd.progress);
check('the map is NOT yet updated mid-slide — origin cell still reads solid to collision/LOS', state.map.walls[1][3] === 1);
// Push the player up against the (still solid, mid-slide) origin cell
// and try to walk east through it — collision must hold regardless of
// how far the visual has progressed (same "no squeezing through" rule
// as a normal half-open door).
state.player.x = 2.9; state.player.y = 1.5;
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 30; i++) L.tick(state, rules);
check('the player cannot walk into the origin cell before the slide fully completes', state.player.x < 3, state.player.x);
L.setMoveIntent(state, 0, 0);
const ticksToFinish = Math.ceil((sd.restDist * 600) / rules.stepMs) + 5;
for (let i = 0; i < ticksToFinish; i++) L.tick(state, rules);
check('the door finishes sliding and settles at state "open"', state.secretDoors[0].state === 'open', state.secretDoors[0]);
check('the origin cell is permanently open once the slide completes', state.map.walls[1][3] === 0);
check('the rest cell (origin + restDist) becomes solid, carrying the SAME wall type the door started with', state.map.walls[1][3 + sd.restDist] === 1, state.map.walls[1][3 + sd.restDist]);
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60; i++) L.tick(state, rules);
check('the player can now walk through the fully-opened corridor', state.player.x > 3.5, state.player.x);
}
// Enemies never trigger a secret door — no equivalent of a normal door's
// AI push-through exists for these at all.
{
const level = secretLevel(0, { enemies: [{ type: 'guard', x: 2.7, y: 1.5, facing: 0 }] });
const state = L.createState(level, rules);
for (let i = 0; i < 60; i++) L.tick(state, rules);
check('a guard standing right next to a secret door never triggers it', state.secretDoors[0].state === 'closed');
}
// validateLevel: must sit on an actual wall cell, must have somewhere to
// go, and — deliberately, unlike a normal/colored door — is NOT assumed
// passable for the main-path reachability check: a secret is optional
// content, so a level that routes its only path through one correctly
// fails, exactly like routing through a plain permanent wall would.
{
const notAWall = { ...secretLevel(0), secretDoors: [{ x: 4, y: 1, dir: 0 }] }; // (4,1) is open floor
const notAWallResult = L.validateLevel(L.buildLevelModel(notAWall));
check('a secret door not sitting on a wall cell is flagged', notAWallResult.issues.some((i) => i.includes('does not sit on a wall cell')), notAWallResult.issues);
const boxedLevel = secretLevel(0);
boxedLevel.walls[1][4] = 1; // seal the corridor immediately east of the origin
const boxedResult = L.validateLevel(L.buildLevelModel(boxedLevel));
check('a secret door with no open corridor in its direction is flagged', boxedResult.issues.some((i) => i.includes('no open corridor')), boxedResult.issues);
const optionalSecretLevel = secretLevel(0); // exit is reachable WITHOUT ever finding the secret
const optionalResult = L.validateLevel(L.buildLevelModel(optionalSecretLevel));
check('a level with an untouched secret door (not on the main path) still validates as reachable', optionalResult.reachable, optionalResult.issues);
// Route the ONLY path to the exit through the secret door's corridor —
// reachability must NOT assume a secret is findable, unlike a normal or
// (key-permitting) colored door.
const gatedLevel = secretLevel(0);
for (let x = 1; x <= 6; x++) gatedLevel.walls[2][x] = 1; // seal the alternate row too, so (3,1) is the only way east
const gatedResult = L.validateLevel(L.buildLevelModel(gatedLevel));
check('a level whose ONLY path runs through an unopened secret door correctly reports the exit unreachable', !gatedResult.reachable, gatedResult.issues);
}
// Save/load round-trip: state/progress (the only genuinely dynamic
// fields) survive, and so does the map's already-baked state for a door
// that finished opening before the save was taken.
{
const level = secretLevel(0);
const state = L.createState(level, rules);
state.player.x = 3.0; state.player.y = 1.5;
L.triggerNearestSecretDoor(state);
for (let i = 0; i < 5; i++) L.tick(state, rules);
const restored = L.deserialize(rules, L.serialize(state));
check('a mid-slide secret door\'s state/progress round-trip through save/load', restored && restored.secretDoors[0].state === 'sliding' && near(restored.secretDoors[0].progress, state.secretDoors[0].progress), restored?.secretDoors[0]);
const legacy = JSON.parse(L.serialize(state));
delete legacy.secretDoors;
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
check('a save predating secret doors deserializes with an empty list, not a crash', restoredLegacy && Array.isArray(restoredLegacy.secretDoors) && restoredLegacy.secretDoors.length === 0, restoredLegacy?.secretDoors);
}
}
// ---------------------------------------------------------------------------
// Sections 7+: shipped level/campaign data (present once the authoring
// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json —
// exists; skipped gracefully before then so this script is runnable from
// step 1 of the build, per the plan's phasing).
// ---------------------------------------------------------------------------
import { existsSync } from 'fs';
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
if (existsSync(campaignPath)) {
section('7. Level solvability + campaign/editor schema parity');
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
for (const camp of campaigns.campaigns) {
let cleared = 0;
for (const m of camp.missions) {
const levelPath = join(ROOT, 'assets/gamedata/wolfenstein', m.levelFile);
const levelJson = JSON.parse(readFileSync(levelPath, 'utf8'));
const model = L.buildLevelModel(levelJson);
const result = L.validateLevel(model);
check(`${camp.id}/${m.id} (${m.levelFile}) validates`, result.valid, result.issues.join('; '));
check(`${camp.id}/${m.id} exit is reachable from playerStart`, result.reachable);
cleared++;
}
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
}
section('8. Campaign progress gating (boundary values)');
for (const camp of campaigns.campaigns) {
const n = camp.missions.length;
for (const cleared of [0, 1, n]) {
const unlocked = camp.missions.map((_, i) => i <= cleared);
const expectedUnlocked = Math.min(cleared + 1, n);
const actualUnlocked = unlocked.filter(Boolean).length;
check(`${camp.id} cleared=${cleared} unlocks exactly ${expectedUnlocked} mission(s)`, actualUnlocked === expectedUnlocked, `got ${actualUnlocked}`);
}
}
} else {
console.log('\n(skipping level/campaign sections — data/wolfenstein-campaigns.json not present yet)');
}
// ---------------------------------------------------------------------------
console.log(`\n${pass} checks passed, ${failures.length} failed.`);
if (failures.length) {
console.log('\nFailures:');
for (const f of failures) console.log(` - ${f}`);
process.exit(1);
}