fertig-classic-games/tools/verifyWolfenstein.js

423 lines
21 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 } 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('guard enemy defined', !!rules.enemyById.guard);
for (const w of rules.weapons) {
check(`${w.id} has positive damage`, w.damage > 0);
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
}
for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange);
}
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, 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, damage: 20, 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, damage: 20, 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, 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, damage: 20, 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);
}
}
// ---------------------------------------------------------------------------
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);
}
// 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);
}
}
// A dead guard stops acting and can't be hit/killed twice.
{
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.setFireHeld(state, true);
L.switchWeapon(state, 'pistol');
let deaths = 0;
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
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');
}
}
// ---------------------------------------------------------------------------
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);
}
// ---------------------------------------------------------------------------
// Sections 6+: 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('6. 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('7. 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);
}