fertig-classic-games/tools/verifyColoradoDefense.js

206 lines
9.3 KiB
JavaScript

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