// Headless verification for Defender. // node tools/verifyDefender.js // Exits non-zero on any failure. // // 1. Wraparound math (wrap/tdelta) round-trip and shortest-path correctness. // 2. Boids seam correctness (neighbors across the wrap seam attract/repel // as if adjacent, not as if worlds apart). // 3. Rescue state machine — every transition in the humanoid lifecycle. // 4. Overdrive meter thresholds and combo multiplier behavior. // 5. No entity leaks across a long soak. // 6. Boss defeat always precedes exactly one levelComplete, tally correct. // 7. Spiral-of-death guard on a huge injected deltaMs. // 8. Determinism — same seed + same input sequence replayed twice. // 9. Monte-carlo bot soak across many seeds through all 5 levels. // 10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety. import { WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE, wrap, tdelta, tdist, createGame, setInput, step, BOSS_PROFILES, bossSpec, } from '../src/games/defender/DefenderLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures += 1; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function runTicks(state, count, input = {}) { setInput(state, input); const events = []; for (let i = 0; i < count; i += 1) events.push(...step(state, STEP_MS)); return events; } // --------------------------------------------------------------------------- console.log('1. Wraparound math'); { check('wrap() folds negative into range', wrap(-10) === WORLD_W - 10); check('wrap() folds overflow into range', wrap(WORLD_W + 25) === 25); check('wrap() is identity inside range', wrap(1234) === 1234); check('tdelta shortest path across seam is small', Math.abs(tdelta(5, WORLD_W - 5)) === 10, `got ${tdelta(5, WORLD_W - 5)}`); check('tdelta sign points the short way', tdelta(5, WORLD_W - 5) < 0); check('tdelta of equal points is 0', tdelta(500, 500) === 0); check('tdelta magnitude never exceeds half the world', Math.abs(tdelta(0, WORLD_W / 2)) <= WORLD_W / 2 + 1e-9); } // --------------------------------------------------------------------------- console.log('2. Boids seam correctness'); { const state = createGame({ seed: 1 }); state.phase = 'wave'; state.spawnQueue = []; state.enemies = [ { id: 901, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: 5, y: 400, vx: 0, vy: 0 }, { id: 902, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: WORLD_W - 5, y: 400, vx: 0, vy: 0 }, ]; state.player.x = 3000; // far from both, out of seek range const before = tdist(state.enemies[0].x, state.enemies[0].y, state.enemies[1].x, state.enemies[1].y); runTicks(state, 30); const [a, b] = state.enemies; const after = tdist(a.x, a.y, b.x, b.y); check('seam-adjacent swarmers perceive each other as close', before < 20, `raw seam gap ${before}`); check('seam-adjacent swarmers stay bounded, not flung apart', after < 400, `wrapped distance grew to ${after}`); check('no NaN positions after seam interaction', Number.isFinite(a.x) && Number.isFinite(b.x)); } // --------------------------------------------------------------------------- console.log('3. Rescue state machine'); { // grabbed -> lost (escaped past Y_SKY) { const state = createGame({ seed: 2 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; h.status = 'grabbed'; h.grabberId = 777; state.enemies = [{ id: 777, type: 'abductor', hp: 2, radius: TUNE.ABDUCTOR_RADIUS, x: h.x, y: Y_SKY + 2, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }]; const ev = runTicks(state, 5); check('grabbed humanoid lost on reaching Y_SKY', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'escaped')); } // grabbed -> falling (carrying abductor dies) { const state = createGame({ seed: 3 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; h.status = 'grabbed'; h.grabberId = 778; h.x = 3000; h.y = 400; state.enemies = [{ id: 778, type: 'abductor', hp: 1, radius: TUNE.ABDUCTOR_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }]; state.shots = [{ x: 3000, y: 400, vx: 0, vy: 0, ttlMs: 500 }]; const ev = runTicks(state, 1); check('humanoid freed when its abductor dies', h.status === 'falling' && ev.some((e) => e.type === 'humanoidFreed')); } // falling -> lost (hits ground) { const state = createGame({ seed: 4 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; h.status = 'falling'; h.y = Y_GROUND - 2; h.vy = TUNE.FALL_SPEED; h.timerMs = 0; state.player.x = wrap(h.x + 3000); // keep the player far away so it can't intercept const ev = runTicks(state, 3); check('falling humanoid lost on hitting ground', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'hitGround')); } // falling -> lost (grab window elapses, never reaches ground) { const state = createGame({ seed: 5 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; // Fall distance over a full GRAB_WINDOW_MS at the constant FALL_SPEED is // ~1120px; start well above that so "hits ground" can't pre-empt this // test of the "grab window elapses" path. h.status = 'falling'; h.y = Y_GROUND - 1400; h.vy = TUNE.FALL_SPEED; h.timerMs = 0; state.player.x = wrap(h.x + 3000); const justBefore = Math.floor((TUNE.GRAB_WINDOW_MS - 3 * STEP_MS) / STEP_MS); runTicks(state, justBefore); const stillFalling = h.status === 'falling'; const ev = runTicks(state, 10); check('grab window not triggered early', stillFalling); check('falling humanoid lost when grab window elapses', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'grabWindow')); } // falling -> carried (player proximity) { const state = createGame({ seed: 6 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; h.status = 'falling'; h.y = 400; h.vy = 0; h.timerMs = 0; state.player.x = h.x; state.player.y = h.y; state.player.vx = 0; state.player.vy = 0; const ev = runTicks(state, 1); check('nearby falling humanoid is auto-picked-up', h.status === 'carried' && state.player.carrying === h.id && ev.some((e) => e.type === 'humanoidPickedUp')); } // carried -> rescued (extraction zone) { const state = createGame({ seed: 7 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; const zoneX = state.extractionZones[0].x; h.status = 'carried'; h.timerMs = 0; state.player.carrying = h.id; state.player.x = zoneX; state.player.y = 400; state.player.vx = 0; state.player.vy = 0; const ev = runTicks(state, 1); check('carried humanoid rescued at extraction zone', h.status === 'rescued' && state.player.carrying === null && state.rescuedThisLevel === 1 && ev.some((e) => e.type === 'humanoidRescued')); } // carried -> lost (timeout) { const state = createGame({ seed: 8 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; h.status = 'carried'; h.timerMs = 0; state.player.carrying = h.id; state.player.x = wrap(state.extractionZones[0].x + WORLD_W / 2); // far from every zone state.player.y = 400; state.player.vx = 0; state.player.vy = 0; const ticks = Math.ceil(TUNE.CARRY_TIMEOUT_MS / STEP_MS) + 2; const ev = runTicks(state, ticks); check('carried humanoid lost after carry timeout', h.status === 'lost' && state.player.carrying === null && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'carryTimeout')); } // carried -> lost (player dies) { const state = createGame({ seed: 9 }); state.phase = 'wave'; state.spawnQueue = []; const h = state.humanoids[0]; h.status = 'carried'; h.timerMs = 0; state.player.carrying = h.id; state.player.invulnMs = 0; state.player.x = 3000; state.player.y = 400; state.enemies = [{ id: 555, type: 'walker', hp: 3, radius: TUNE.WALKER_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, homeX: 3000, dir: 1, fireCooldownMs: 9999 }]; const ev = runTicks(state, 1); check('carried humanoid lost when player dies', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'playerDied')); check('player death event also fired', ev.some((e) => e.type === 'playerDied')); } // illegal transition: can't pick up a second humanoid while already carrying one { const state = createGame({ seed: 10 }); state.phase = 'wave'; state.spawnQueue = []; const [h1, h2] = state.humanoids; h1.status = 'carried'; h1.timerMs = 0; h2.status = 'falling'; h2.y = 400; h2.vy = 0; h2.timerMs = 0; h2.x = wrap(h1.x); state.player.carrying = h1.id; state.player.x = h2.x; state.player.y = 400; state.player.vx = 0; state.player.vy = 0; runTicks(state, 1); check('already-carrying player cannot pick up a second humanoid', state.player.carrying === h1.id && h2.status === 'falling'); } } // --------------------------------------------------------------------------- console.log('4. Overdrive meter & combo multiplier'); { const state = createGame({ seed: 11 }); state.phase = 'wave'; state.spawnQueue = []; let readyFired = 0; let startFired = 0; let endFired = 0; let prevMeter = 0; let monotonicOnFill = true; let killsToReady = 0; while (state.overdriveMeter < 1 && killsToReady < 60) { killsToReady += 1; state.enemies = [{ id: 1000 + killsToReady, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state.player.x, y: state.player.y, vx: 0, vy: 0 }]; state.shots = [{ x: state.player.x, y: state.player.y, vx: 0, vy: 0, ttlMs: 500 }]; prevMeter = state.overdriveMeter; const ev = runTicks(state, 1); if (state.overdriveMeter < prevMeter) monotonicOnFill = false; readyFired += ev.filter((e) => e.type === 'overdriveReady').length; } check('overdrive meter fills monotonically with kills', monotonicOnFill); check('expected number of kills to fill the meter', killsToReady === Math.ceil(1 / TUNE.OVERDRIVE_FILL_PER_KILL), `took ${killsToReady} kills`); check('overdriveReady fires exactly once at the 1.0 crossing', readyFired === 1, `fired ${readyFired} times`); check('multiplier escalated across the rapid kill chain', state.multiplier > 1, `multiplier=${state.multiplier}`); const startEv = runTicks(state, 1, { overdrive: true }); startFired = startEv.filter((e) => e.type === 'overdriveStart').length; check('overdriveStart fires exactly once on trigger', startFired === 1 && state.overdriveActive === true); const ticksForFullDrain = Math.ceil(TUNE.OVERDRIVE_DURATION_MS / (STEP_MS * TUNE.OVERDRIVE_TIMESCALE)) + 5; const endEv = runTicks(state, ticksForFullDrain, { overdrive: false }); endFired = endEv.filter((e) => e.type === 'overdriveEnd').length; check('overdriveEnd fires exactly once after duration elapses', endFired === 1 && state.overdriveActive === false, `fired ${endFired} times, active=${state.overdriveActive}`); // combo reset after a gap const state2 = createGame({ seed: 12 }); state2.phase = 'wave'; state2.spawnQueue = []; state2.enemies = [{ id: 2001, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state2.player.x, y: state2.player.y, vx: 0, vy: 0 }]; state2.shots = [{ x: state2.player.x, y: state2.player.y, vx: 0, vy: 0, ttlMs: 500 }]; runTicks(state2, 1); const multAfterOneKill = state2.multiplier; runTicks(state2, Math.ceil((TUNE.COMBO_WINDOW_MS + 200) / STEP_MS)); check('combo multiplier resets after the combo window elapses', multAfterOneKill >= 1 && state2.multiplier === 1, `after=${state2.multiplier}`); } // --------------------------------------------------------------------------- console.log('5. No entity leaks / bounded arrays across a soak'); { const state = createGame({ seed: 13 }); let maxEnemies = 0; let maxShots = 0; let maxEnemyShots = 0; let maxHumanoids = 0; for (let i = 0; i < 6000; i += 1) { setInput(state, { right: i % 120 < 60, fire: true, overdrive: state.overdriveMeter >= 1 }); step(state, STEP_MS); maxEnemies = Math.max(maxEnemies, state.enemies.length); maxShots = Math.max(maxShots, state.shots.length); maxEnemyShots = Math.max(maxEnemyShots, state.enemyShots.length); maxHumanoids = Math.max(maxHumanoids, state.humanoids.length); if (state.over) break; } check('enemy count stays bounded', maxEnemies < 200, `max ${maxEnemies}`); check('player shot count stays bounded', maxShots < 500, `max ${maxShots}`); check('enemy shot count stays bounded', maxEnemyShots < 500, `max ${maxEnemyShots}`); check('humanoid count stays bounded near per-level count', maxHumanoids <= TUNE.HUMANOIDS_PER_LEVEL + 1, `max ${maxHumanoids}`); } // --------------------------------------------------------------------------- console.log('6. Boss defeat -> exactly one levelComplete, tally correct'); { const state = createGame({ seed: 14 }); state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = []; let ev = runTicks(state, 1); // spawns the boss check('boss spawns from bossIntro', state.boss != null && ev.some((e) => e.type === 'bossSpawn')); state.boss.hp = 1; state.shots = [{ x: state.boss.x, y: state.boss.y, vx: 0, vy: 0, ttlMs: 500 }]; ev = runTicks(state, 1); const defeatIdx = ev.findIndex((e) => e.type === 'bossDefeated'); const completeCount = ev.filter((e) => e.type === 'levelComplete').length; check('bossDefeated fires', defeatIdx >= 0); check('exactly one levelComplete follows boss defeat', completeCount === 1, `got ${completeCount}`); const complete = ev.find((e) => e.type === 'levelComplete'); check('levelComplete tally matches rescued/lost counts', complete && complete.rescued === state.rescuedThisLevel && complete.lost === state.lostThisLevel); } // --------------------------------------------------------------------------- console.log('7. Spiral-of-death guard'); { const state = createGame({ seed: 15 }); const before = state.timeMs; step(state, 5000); // a huge delta, e.g. a backgrounded tab waking up const advanced = state.timeMs - before; check('a huge delta only advances MAX_STEPS worth of sim time', advanced <= MAX_STEPS * STEP_MS + 1e-6, `advanced ${advanced}ms`); check('leftover accumulator is discarded rather than replayed', state.accumulatorMs === 0, `accumulatorMs=${state.accumulatorMs}`); } // --------------------------------------------------------------------------- console.log('8. Determinism'); { function scriptedInputAt(i) { const p = i % 240; return { left: p < 40, right: p >= 40 && p < 90, up: p >= 90 && p < 110, down: p >= 150 && p < 170, fire: true, overdrive: p === 200, }; } function replay(seed, ticks) { const s = createGame({ seed }); const log = []; for (let i = 0; i < ticks; i += 1) { setInput(s, scriptedInputAt(i)); log.push(...step(s, STEP_MS)); } return { s, log }; } const a = replay(42, 3000); const b = replay(42, 3000); const same = JSON.stringify(a.log) === JSON.stringify(b.log); check('same seed + same inputs produce identical event streams', same); check('same seed + same inputs produce identical final score', a.s.score === b.s.score, `${a.s.score} vs ${b.s.score}`); check('same seed + same inputs produce identical final state shape', JSON.stringify(a.s) === JSON.stringify(b.s)); } // --------------------------------------------------------------------------- console.log('9. Monte-carlo bot soak (all 5 levels)'); { function nearestEnemyX(state) { let best = null; let bestD = Infinity; for (const e of state.enemies) { const d = Math.abs(tdelta(state.player.x, e.x)); if (d < bestD) { bestD = d; best = e; } } return best; } function botInput(state) { const p = state.player; let targetX = p.x; let targetY = (Y_GROUND + 400) / 2; if (p.carrying != null) { const zone = state.extractionZones[0]; targetX = zone.x; targetY = 400; } else { const falling = state.humanoids.find((h) => h.status === 'falling'); if (falling) { targetX = falling.x; targetY = falling.y; } else { const e = nearestEnemyX(state); if (e) { targetX = e.x; targetY = e.y; } } } const dx = tdelta(p.x, targetX); const dy = targetY - p.y; return { left: dx < -8, right: dx > 8, up: dy < -8, down: dy > 8, fire: true, overdrive: state.overdriveMeter >= 1, }; } let seedsRun = 0; let victories = 0; let gameOvers = 0; const SEEDS = 8; const MAX_TICKS = 200000; // generous safety valve; a healthy sim finishes well inside this for (let seed = 1; seed <= SEEDS; seed += 1) { const state = createGame({ seed: seed * 1000 + 7 }); let ticks = 0; let invariantsOk = true; while (!state.over && ticks < MAX_TICKS) { setInput(state, botInput(state)); step(state, STEP_MS); ticks += 1; if (!Number.isFinite(state.player.x) || !Number.isFinite(state.player.y)) invariantsOk = false; if (state.level < 1 || state.level > TUNE.LEVEL_COUNT) invariantsOk = false; if (state.multiplier < 1 || state.multiplier > TUNE.MULT_MAX) invariantsOk = false; if (state.overdriveMeter < 0 || state.overdriveMeter > 1 + 1e-9) invariantsOk = false; if (!invariantsOk) break; } seedsRun += 1; check(`seed ${seed}: invariants held every tick`, invariantsOk); check(`seed ${seed}: run terminated (won or lost) within budget`, state.over, `stopped at ${ticks} ticks, phase=${state.phase}`); if (state.victory) victories += 1; if (state.over && !state.victory) gameOvers += 1; } check('every seed in the soak terminated', seedsRun === SEEDS); console.log(` info: ${victories}/${SEEDS} bot runs reached victory, ${gameOvers}/${SEEDS} ended in game over`); } // --------------------------------------------------------------------------- console.log('10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety'); { const VALID_MOVES = new Set(['ring', 'spread', 'spiral', 'aimed', 'wall', 'reinforce']); check('one boss profile authored per level', BOSS_PROFILES.length === TUNE.LEVEL_COUNT, `${BOSS_PROFILES.length} profiles vs ${TUNE.LEVEL_COUNT} levels`); let prevHp = -Infinity; let prevCooldown = Infinity; let hpMonotonic = true; let cooldownMonotonic = true; let allMovesValid = true; let anyDualPhase = false; for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) { const spec = bossSpec(level); const profile = BOSS_PROFILES[level - 1]; if (spec.hp <= prevHp) hpMonotonic = false; prevHp = spec.hp; if (profile.cooldownMs >= prevCooldown) cooldownMonotonic = false; prevCooldown = profile.cooldownMs; for (const m of profile.moves) if (!VALID_MOVES.has(m)) allMovesValid = false; if (profile.phase2Moves) { anyDualPhase = true; for (const m of profile.phase2Moves) if (!VALID_MOVES.has(m)) allMovesValid = false; } } check('boss HP strictly increases level over level', hpMonotonic); check('boss attack cooldown strictly shortens level over level (attacks come faster)', cooldownMonotonic); check('every authored move name is a recognized attack pattern', allMovesValid); check('later levels introduce a second, harder attack phase', anyDualPhase); // Live-sim: spawn each level's boss directly and confirm its phase-1 moves cycle // in the declared round-robin order, and phase-2 moves take over once wounded. for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) { const profile = BOSS_PROFILES[level - 1]; const state = createGame({ seed: 900 + level, startLevel: level }); state.player.invulnMs = 999999; // isolate attack-pattern dispatch from collision outcomes state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = []; runTicks(state, 1); // spawns the boss const ticksPerCycle = Math.ceil(profile.cooldownMs / STEP_MS) + 2; const observed = []; for (let i = 0; i < profile.moves.length; i += 1) { const ev = runTicks(state, ticksPerCycle); const mv = ev.find((e) => e.type === 'bossMove'); if (mv) observed.push(mv.move); } const matches = observed.length === profile.moves.length && observed.every((m, i) => m === profile.moves[i]); check(`level ${level} (${profile.name}) phase-1 moves cycle in declared order`, matches, `expected ${JSON.stringify(profile.moves)}, observed ${JSON.stringify(observed)}`); if (profile.phase2Moves) { state.boss.hp = 1; // force the phase-2 threshold on the next tick const ev2 = runTicks(state, ticksPerCycle); const phaseEv = ev2.find((e) => e.type === 'bossPhaseChange'); const mv2 = ev2.find((e) => e.type === 'bossMove'); check(`level ${level} (${profile.name}) drops into phase 2 when wounded`, !!phaseEv && phaseEv.phase === 2); check(`level ${level} (${profile.name}) phase-2 move comes from its harder move set`, !!mv2 && profile.phase2Moves.includes(mv2.move), `got ${mv2 && mv2.move}`); } } } // --------------------------------------------------------------------------- if (failures > 0) { console.error(`\n${failures} check(s) FAILED`); process.exit(1); } else { console.log('\nAll checks passed.'); }