438 lines
21 KiB
JavaScript
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.');
|
|
}
|