#!/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); }