#!/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); check('9mm ammo type defined', !!rules.ammoTypeById['9mm']); for (const w of rules.weapons) { check(`${w.id} has positive cooldown`, w.cooldownMs > 0); check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', w.fireMode); 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)); } } 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); } // 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.pistol = 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('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); }