// Headless verification for Colorado Defense. // node tools/verifyColoradoDefense.js // Exits non-zero on any failure. // // 1. Ground-layout invariants (9 slots, 3 bases + 6 cities, symmetric spacing). // 2. City-pool draw correctness + malformed-pool fallback. // 3. Difficulty escalation monotonicity across waves 1-40 (bounded). // 4. MIRV step-unlock threshold. // 5. Scripted explosion/collision fixture. // 6. Palette-progression monotonicity. // 7. Wave-complete bonus breakdown (recap data) matches the score delta. // 8. Monte-carlo "always fire at nearest incoming missile" bot run. import { BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities, DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance, PALETTES, paletteIndexForWave, createGame, step, pickFiringBase, fireInterceptor, lerpPos, explosionRadius, } from '../src/games/coloradodefense/ColoradoDefenseLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures += 1; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } // ── 1. Ground layout ───────────────────────────────────────────────────────── console.log('Ground layout'); { check('9 slots total', GROUND_SLOTS === 9); check('3 base slots', BASE_SLOTS.length === 3); check('6 city slots', CITY_SLOTS.length === 6); const allSlots = [...BASE_SLOTS, ...CITY_SLOTS].sort((a, b) => a - b); check('slots partition 0..8 exactly', allSlots.join(',') === '0,1,2,3,4,5,6,7,8'); check('classic arrangement Base,City,City,City,Base,City,City,City,Base', BASE_SLOTS.join(',') === '0,4,8' && CITY_SLOTS.join(',') === '1,2,3,5,6,7'); const xs = Array.from({ length: GROUND_SLOTS }, (_, i) => slotX(i)); let increasing = true; for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) increasing = false; check('slot positions strictly increasing left to right', increasing); const mid = (xs[0] + xs[8]) / 2; check('layout symmetric around center', Math.abs((xs[4]) - mid) < 1, `slot4=${xs[4]} mid=${mid}`); } // ── 2. City pool ────────────────────────────────────────────────────────────── console.log('City pool'); { const rng = mulberry32(42); for (const seed of [1, 2, 3, 999, 123456]) { const r = mulberry32(seed); const picked = pickCities(DEFAULT_CITIES, r); check(`seed ${seed}: exactly 6 cities`, picked.length === 6); check(`seed ${seed}: unique cities`, new Set(picked).size === 6, picked.join(',')); check(`seed ${seed}: all from pool`, picked.every((n) => DEFAULT_CITIES.includes(n))); } const empty = pickCities([], rng); check('empty pool falls back safely (no throw, bounded length)', empty.length === 6); const small = pickCities(['A', 'B'], rng); check('pool smaller than 6 cycles without throwing', small.length === 6); } // ── 3. Escalation monotonicity ──────────────────────────────────────────────── console.log('Escalation monotonicity (waves 1-40)'); { let spawnOk = true; let fallOk = true; let quotaOk = true; let spawnBounded = true; let fallBounded = true; let quotaBounded = true; for (let w = 2; w <= 40; w += 1) { if (spawnInterval(w) > spawnInterval(w - 1)) spawnOk = false; if (fallSpeed(w) < fallSpeed(w - 1)) fallOk = false; if (waveQuota(w) < waveQuota(w - 1)) quotaOk = false; } for (let w = 1; w <= 40; w += 1) { if (spawnInterval(w) < TUNE.SPAWN_MS_MIN) spawnBounded = false; if (fallSpeed(w) > TUNE.FALL_SPEED_MAX) fallBounded = false; if (waveQuota(w) > TUNE.WAVE_QUOTA_MAX) quotaBounded = false; } check('spawn interval never increases with wave', spawnOk); check('fall speed never decreases with wave', fallOk); check('wave quota never decreases with wave', quotaOk); check('spawn interval bounded at floor', spawnBounded); check('fall speed bounded at cap', fallBounded); check('wave quota bounded at cap', quotaBounded); } // ── 4. MIRV step-unlock ──────────────────────────────────────────────────────── console.log('MIRV step-unlock'); { let belowZero = true; for (let w = 1; w < TUNE.MIRV_WAVE; w += 1) if (mirvChance(w) !== 0) belowZero = false; check(`mirvChance is 0 before wave ${TUNE.MIRV_WAVE}`, belowZero); check(`mirvChance > 0 at wave ${TUNE.MIRV_WAVE}`, mirvChance(TUNE.MIRV_WAVE) > 0); check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX); } // ── 5. Explosion/collision fixture ──────────────────────────────────────────── console.log('Explosion/collision fixture'); { const sim = createGame(DEFAULT_CITIES, 7); const target = sim.cities[0]; // A slow-descending missile (long dur) barely drifts during the // interceptor's flight time, so a shot at its current position still lands. sim.enemyMissiles.push({ id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y, targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true, }); const missilePos = lerpPos(sim.enemyMissiles[0]); const base = pickFiringBase(sim, missilePos.x, missilePos.y); fireInterceptor(sim, base, missilePos.x, missilePos.y); let destroyed = false; let steps = 0; while (!destroyed && steps < 200) { const events = step(sim, 16); if (events.some((e) => e.type === 'missileDestroyed')) destroyed = true; steps += 1; } check('interceptor blast destroys missile within radius', destroyed, `steps=${steps}`); check('score increased on kill', sim.score > 0, `score=${sim.score}`); const sim2 = createGame(DEFAULT_CITIES, 8); const target2 = sim2.cities[0]; sim2.enemyMissiles.push({ id: sim2.nextId++, fromX: target2.x, fromY: 0, toX: target2.x, toY: target2.y, targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitAt: 2, splitDone: true, }); const far = lerpPos(sim2.enemyMissiles[0]); fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y); for (let i = 0; i < 60; i += 1) step(sim2, 16); check('missile far outside blast radius survives', sim2.enemyMissiles.length === 1); } // ── 5b. Chain-reaction fixture ───────────────────────────────────────────────── console.log('Chain-reaction fixture'); { const durSim = createGame(DEFAULT_CITIES, 1); durSim.spawnExplosion(0, 0, 'player'); const normalTotal = durSim.explosions[0].totalMs; durSim.spawnExplosion(0, 0, 'chain', { lethal: true, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS, maxR: TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT, }); const chainExp = durSim.explosions[1]; check('chain explosion lasts exactly CHAIN_EXTRA_GROW_MS longer than a normal one', chainExp.totalMs - normalTotal === TUNE.CHAIN_EXTRA_GROW_MS, `normal=${normalTotal} chain=${chainExp.totalMs}`); check('the extra time is spent growing to full size, not lingering at it', explosionRadius({ t: TUNE.BLAST_GROW_MS + 1, maxR: 90, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS }) < 90, 'radius already maxed before the slower grow phase finished'); check('chain explosion max radius is CHAIN_R_MULT times a normal one', chainExp.maxR === TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT, `expected ${TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT} got ${chainExp.maxR}`); const sim = createGame(DEFAULT_CITIES, 21); const target = sim.cities[0]; sim.enemyMissiles.push({ id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y, targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true, }); const primaryPos = lerpPos(sim.enemyMissiles[0]); const base = pickFiringBase(sim, primaryPos.x, primaryPos.y); fireInterceptor(sim, base, primaryPos.x, primaryPos.y); // Note: the interceptor itself takes real flight time to arrive (the // original blast doesn't start at t=0), so a fixed-schedule secondary // missile can't be pre-timed reliably. Instead, inject a near-stationary // missile inside the chain's radius only once the chain blast is confirmed // under way (well after the original — which shares the same radius — // would have already faded), so the chain is unambiguously the killer. let primaryKillElapsed = null; let chainExplosionElapsed = null; let secondaryInjected = false; let secondaryKilledByChain = false; let elapsed = 0; for (let steps = 0; steps < 300; steps += 1) { const events = step(sim, 16); elapsed += 16; for (const ev of events) { if (ev.type === 'missileDestroyed' && !ev.chained && primaryKillElapsed === null) primaryKillElapsed = elapsed; if (ev.type === 'explosion' && ev.owner === 'chain' && chainExplosionElapsed === null) chainExplosionElapsed = elapsed; if (ev.type === 'missileDestroyed' && ev.chained) secondaryKilledByChain = true; } // The original blast is only guaranteed faded 670ms after IT spawns // (~primaryKillElapsed). The chain (spawned ~250ms after the kill) grows // to a 135px max over 1100ms, passing the 50px test distance ~407ms into // its own life (250+407=657ms after the kill) — wait past both before // injecting, well inside its 1670ms total lethal window. if (!secondaryInjected && primaryKillElapsed !== null && elapsed >= primaryKillElapsed + 1000) { sim.enemyMissiles.push({ id: sim.nextId++, fromX: primaryPos.x - 50, fromY: primaryPos.y, toX: primaryPos.x - 50, toY: primaryPos.y, targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true, }); secondaryInjected = true; } } check('primary missile destroyed by direct hit', primaryKillElapsed !== null); check('a chain explosion follows ~CHAIN_DELAY_MS later', chainExplosionElapsed !== null && Math.abs((chainExplosionElapsed - primaryKillElapsed) - TUNE.CHAIN_DELAY_MS) <= 16, `primary=${primaryKillElapsed} chain=${chainExplosionElapsed}`); check('missile dropped inside the chain blast is destroyed by it', secondaryKilledByChain); check('chain reaction bounded to one extra tier (no dangling pending chains)', sim.pendingChains.length === 0); } // ── 6. Palette progression ──────────────────────────────────────────────────── console.log('Palette progression'); { let monotonic = true; for (let w = 2; w <= 60; w += 1) { if (paletteIndexForWave(w) < paletteIndexForWave(w - 1)) monotonic = false; } check('palette index monotonic non-decreasing', monotonic); check('palette index clamps at array end', paletteIndexForWave(9999) === PALETTES.length - 1); } // ── 7. Wave-complete bonus breakdown ────────────────────────────────────────── console.log('Wave-complete bonus breakdown'); { const sim = createGame(DEFAULT_CITIES, 99); sim.cities[0].alive = false; // 5 cities survive sim.bases[0].ammo = 4; sim.bases[1].ammo = 0; sim.bases[2].ammo = 7; const scoreBefore = sim.score; const events = []; const origEmit = sim.emit.bind(sim); sim.emit = (type, data) => { events.push({ type, ...data }); origEmit(type, data); }; sim.completeWave(); const e = events.find((ev) => ev.type === 'waveComplete'); check('waveComplete event emitted', !!e); check('aliveCityCount matches surviving cities', e.aliveCityCount === 5, `got ${e.aliveCityCount}`); check('cityBonus = aliveCityCount * CITY_BONUS', e.cityBonus === 5 * TUNE.CITY_BONUS); check('preAmmo snapshot matches pre-refill ammo', e.preAmmo.join(',') === '4,0,7', e.preAmmo.join(',')); check('totalAmmo sums preAmmo', e.totalAmmo === 11, `got ${e.totalAmmo}`); check('ammoBonus = totalAmmo * SCORE_PER_KILL', e.ammoBonus === 11 * TUNE.SCORE_PER_KILL); check('score increased by exactly cityBonus + ammoBonus', sim.score === scoreBefore + e.cityBonus + e.ammoBonus, `before=${scoreBefore} after=${sim.score}`); check('surviving bases refilled to full ammo after transition', sim.bases[0].ammo === TUNE.BASE_AMMO && sim.bases[2].ammo === TUNE.BASE_AMMO); } // ── 8. Monte-carlo bot run ──────────────────────────────────────────────────── console.log('Monte-carlo bot run'); { for (const seed of [1, 2, 3, 4, 5]) { const sim = createGame(DEFAULT_CITIES, seed); let steps = 0; let scoreRegressed = false; let cityCountIncreased = false; let nanFound = false; let prevScore = 0; let prevCities = sim.aliveCities().length; const MAX_STEPS = 20000; // ~320s of sim time at 16ms/step while (sim.status === 'playing' && steps < MAX_STEPS) { // Bot: fire at whichever enemy missile is closest to impact (highest t), // but only one interceptor in flight at a time so ammo isn't wasted on // redundant shots at the same target. if (sim.enemyMissiles.length && sim.interceptors.length === 0) { let soonest = null; let soonestT = -1; for (const m of sim.enemyMissiles) { if (m.t > soonestT) { soonestT = m.t; soonest = m; } } if (soonest) { const pos = lerpPos(soonest); const base = pickFiringBase(sim, pos.x, pos.y); if (base) fireInterceptor(sim, base, pos.x, pos.y); } } step(sim, 16); steps += 1; if (sim.score < prevScore) scoreRegressed = true; prevScore = sim.score; const aliveCities = sim.aliveCities().length; if (aliveCities > prevCities) cityCountIncreased = true; prevCities = aliveCities; for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions]) { for (const e of list) { if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true; } } } check(`seed ${seed}: no NaN values`, !nanFound); check(`seed ${seed}: score never regressed`, !scoreRegressed); check(`seed ${seed}: city count never increased`, !cityCountIncreased); check(`seed ${seed}: game over exactly when all cities dead`, sim.status === 'gameover' ? sim.aliveCities().length === 0 : true); check(`seed ${seed}: terminates within step bound`, steps < MAX_STEPS, `steps=${steps}`); console.log(` seed ${seed}: status=${sim.status} wave=${sim.wave} score=${sim.score} steps=${steps}`); } } console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); process.exit(failures ? 1 : 0);