fertig-classic-games/tools/verifyDefender.js

438 lines
21 KiB
JavaScript

// 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.');
}