547 lines
27 KiB
JavaScript
547 lines
27 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.
|
|
// 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8).
|
|
// 5. Scripted explosion/collision fixture.
|
|
// 5c. Bonus plane fixture (unlock wave, crossing speed, hit/score/debris).
|
|
// 5d. Bonus UFO fixture (unlock wave, weaving/shots, hit/score/debris).
|
|
// 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, HEIGHT, WIDTH, GROUND_Y, 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);
|
|
}
|
|
|
|
// ── 4b. MIRV split-zone geometry ────────────────────────────────────────────────
|
|
|
|
console.log('MIRV split-zone geometry');
|
|
{
|
|
// spawnEnemyMissile() itself: force isMirv=true (rng()=0 < any nonzero
|
|
// mirvChance) and check the resulting splitsLeft/splitY at both tiers.
|
|
const spawnLow = createGame(DEFAULT_CITIES, 3);
|
|
spawnLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1;
|
|
spawnLow.rng = () => 0;
|
|
const missileLow = spawnLow.spawnEnemyMissile();
|
|
check('low-wave spawn becomes a MIRV with splitsLeft=1',
|
|
missileLow.kind === 'mirv' && missileLow.splitsLeft === 1);
|
|
check('low-wave MIRV splitY lands in SPLIT_ZONE_1',
|
|
missileLow.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileLow.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1],
|
|
`splitY=${missileLow.splitY}`);
|
|
|
|
const spawnHigh = createGame(DEFAULT_CITIES, 3);
|
|
spawnHigh.wave = TUNE.MIRV_RESPLIT_WAVE;
|
|
spawnHigh.rng = () => 0;
|
|
const missileHigh = spawnHigh.spawnEnemyMissile();
|
|
check('wave>=MIRV_RESPLIT_WAVE spawn becomes a MIRV with splitsLeft=2',
|
|
missileHigh.kind === 'mirv' && missileHigh.splitsLeft === 2);
|
|
check('its first splitY still lands in SPLIT_ZONE_1',
|
|
missileHigh.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileHigh.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1],
|
|
`splitY=${missileHigh.splitY}`);
|
|
|
|
// Full flight, low wave: exactly one split, and the children never re-split.
|
|
// (waveSpawned pinned to waveQuota so the sim's own spawn timer can't add
|
|
// unrelated missiles that would pollute the length-based split count.)
|
|
const simLow = createGame(DEFAULT_CITIES, 5);
|
|
simLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1;
|
|
simLow.waveSpawned = simLow.waveQuota;
|
|
const t1 = simLow.cities[0];
|
|
simLow.enemyMissiles.push({
|
|
id: simLow.nextId++, fromX: t1.x, fromY: 0, toX: t1.x, toY: t1.y,
|
|
targetSlot: t1.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv',
|
|
splitsLeft: 1, splitY: HEIGHT * 0.25,
|
|
});
|
|
let splitCountLow = 0;
|
|
let childKinds = [];
|
|
for (let i = 0; i < 250; i += 1) {
|
|
const before = simLow.enemyMissiles.length;
|
|
step(simLow, 16);
|
|
if (simLow.enemyMissiles.length > before) {
|
|
splitCountLow += 1;
|
|
childKinds = simLow.enemyMissiles.filter((m) => m.kind !== 'mirv').map((m) => m.kind);
|
|
}
|
|
}
|
|
check('low-wave MIRV splits exactly once over its full flight', splitCountLow === 1, `splitCount=${splitCountLow}`);
|
|
check('its children never re-split', childKinds.length > 0 && childKinds.every((k) => k === 'single'));
|
|
|
|
// Full flight, wave >= MIRV_RESPLIT_WAVE: splits twice, second time in zone 2.
|
|
const simHigh = createGame(DEFAULT_CITIES, 6);
|
|
simHigh.wave = TUNE.MIRV_RESPLIT_WAVE;
|
|
simHigh.waveSpawned = simHigh.waveQuota;
|
|
const t2 = simHigh.cities[0];
|
|
simHigh.enemyMissiles.push({
|
|
id: simHigh.nextId++, fromX: t2.x, fromY: 0, toX: t2.x, toY: t2.y,
|
|
targetSlot: t2.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv',
|
|
splitsLeft: 2, splitY: HEIGHT * 0.25,
|
|
});
|
|
let splitEvents = 0;
|
|
let sawZone2SplitY = false;
|
|
// Stops well before the trunk's own ~5000ms impact — once it lands and
|
|
// completeWave() fires, waveSpawned resets and the spawn timer could add
|
|
// unrelated missiles again, polluting the length-based split count.
|
|
for (let i = 0; i < 280; i += 1) {
|
|
const before = simHigh.enemyMissiles.length;
|
|
step(simHigh, 16);
|
|
if (simHigh.enemyMissiles.length > before) {
|
|
splitEvents += 1;
|
|
const trunk = simHigh.enemyMissiles.find((m) => m.kind === 'mirv');
|
|
if (trunk && trunk.splitY !== null) {
|
|
const frac = trunk.splitY / HEIGHT;
|
|
if (frac >= TUNE.SPLIT_ZONE_2[0] && frac <= TUNE.SPLIT_ZONE_2[1]) sawZone2SplitY = true;
|
|
}
|
|
}
|
|
}
|
|
check('wave>=MIRV_RESPLIT_WAVE MIRV splits exactly twice', splitEvents === 2, `splitEvents=${splitEvents}`);
|
|
check('its second split is scheduled in SPLIT_ZONE_2', sawZone2SplitY);
|
|
}
|
|
|
|
// ── 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', splitsLeft: 0,
|
|
});
|
|
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', splitsLeft: 0,
|
|
});
|
|
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', splitsLeft: 0,
|
|
});
|
|
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', splitsLeft: 0,
|
|
});
|
|
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);
|
|
}
|
|
|
|
// ── 5c. Bonus plane fixture ────────────────────────────────────────────────────
|
|
|
|
console.log('Bonus plane fixture');
|
|
{
|
|
// Locked before PLANE_WAVE, even given plenty of time. waveQuota pinned to
|
|
// Infinity so a real wave can never complete mid-test and silently bump
|
|
// `wave` past the threshold being tested (ordinary missile spawning is
|
|
// left running — it's irrelevant noise for these plane-only assertions).
|
|
const simLocked = createGame(DEFAULT_CITIES, 40);
|
|
simLocked.wave = TUNE.PLANE_WAVE - 1;
|
|
simLocked.waveQuota = Infinity;
|
|
for (let i = 0; i < Math.ceil((TUNE.PLANE_SPAWN_MAX_MS + 2000) / 16); i += 1) step(simLocked, 16);
|
|
check('no plane spawns before PLANE_WAVE', simLocked.plane === null);
|
|
|
|
// Unlocked: spawns once its timer elapses.
|
|
const simSpawn = createGame(DEFAULT_CITIES, 41);
|
|
simSpawn.wave = TUNE.PLANE_WAVE;
|
|
simSpawn.nextPlaneAt = 100; // force a near-immediate spawn, deterministically
|
|
for (let i = 0; i < 20 && !simSpawn.plane; i += 1) step(simSpawn, 16);
|
|
check('plane spawns once its timer elapses at/after PLANE_WAVE', simSpawn.plane !== null);
|
|
if (simSpawn.plane) {
|
|
const { x, dir } = simSpawn.plane;
|
|
check('plane starts just off the correct edge for its direction',
|
|
(dir === 1 && x < 0) || (dir === -1 && x > WIDTH), `dir=${dir} x=${x}`);
|
|
check('plane speed crosses the full width in exactly PLANE_CROSS_MS',
|
|
Math.abs(simSpawn.plane.speed * TUNE.PLANE_CROSS_MS - WIDTH) < 1e-6);
|
|
}
|
|
|
|
// Hit: a lethal explosion at the plane's position destroys it, scores
|
|
// PLANE_SCORE exactly, and spawns debris that keeps the plane's heading.
|
|
const simHit = createGame(DEFAULT_CITIES, 42);
|
|
simHit.wave = TUNE.PLANE_WAVE;
|
|
simHit.waveQuota = Infinity;
|
|
const planeY = HEIGHT * TUNE.PLANE_Y_FRAC;
|
|
const planeSpeed = WIDTH / TUNE.PLANE_CROSS_MS;
|
|
simHit.plane = { id: simHit.nextId++, x: 500, y: planeY, dir: 1, speed: planeSpeed, vx: planeSpeed, vy: 0 };
|
|
const scoreBefore = simHit.score;
|
|
simHit.spawnExplosion(500, planeY, 'player');
|
|
let planeDestroyedEvent = null;
|
|
for (let i = 0; i < 50 && !planeDestroyedEvent; i += 1) {
|
|
const events = step(simHit, 16);
|
|
planeDestroyedEvent = events.find((e) => e.type === 'planeDestroyed') || planeDestroyedEvent;
|
|
}
|
|
check('a lethal explosion at the plane destroys it', planeDestroyedEvent !== null);
|
|
check('plane kill awards exactly PLANE_SCORE', simHit.score === scoreBefore + TUNE.PLANE_SCORE,
|
|
`before=${scoreBefore} after=${simHit.score}`);
|
|
check('plane is cleared after being destroyed', simHit.plane === null);
|
|
const [dLo, dHi] = TUNE.PLANE_DEBRIS_COUNT;
|
|
check('debris count is within PLANE_DEBRIS_COUNT range',
|
|
simHit.debris.length >= dLo && simHit.debris.length <= dHi, `count=${simHit.debris.length}`);
|
|
check("debris keeps the plane's horizontal momentum (rightward here)",
|
|
simHit.debris.length > 0 && simHit.debris.every((p) => p.vx > 0));
|
|
|
|
// Debris eventually falls and clears, without exceeding the ground band.
|
|
let debrisSteps = 0;
|
|
let maxY = 0;
|
|
while (simHit.debris.length > 0 && debrisSteps < 3000) {
|
|
for (const p of simHit.debris) maxY = Math.max(maxY, p.y);
|
|
step(simHit, 16);
|
|
debrisSteps += 1;
|
|
}
|
|
check('debris eventually clears (lands) within a bounded number of steps',
|
|
simHit.debris.length === 0, `steps=${debrisSteps}`);
|
|
check('debris never falls past the ground band', maxY <= GROUND_Y + 40, `maxY=${maxY}`);
|
|
}
|
|
|
|
// ── 5d. Bonus UFO fixture ───────────────────────────────────────────────────────
|
|
|
|
console.log('Bonus UFO fixture');
|
|
{
|
|
// Locked before UFO_WAVE, even given plenty of time.
|
|
const simLocked = createGame(DEFAULT_CITIES, 50);
|
|
simLocked.wave = TUNE.UFO_WAVE - 1;
|
|
simLocked.waveQuota = Infinity;
|
|
for (let i = 0; i < Math.ceil((TUNE.UFO_SPAWN_MAX_MS + 2000) / 16); i += 1) step(simLocked, 16);
|
|
check('no UFO spawns before UFO_WAVE', simLocked.ufo === null);
|
|
|
|
// Unlocked: spawns with the right edge/speed, weaves within ±UFO_MAX_ANGLE_DEG,
|
|
// and fires exactly UFO_SHOTS MIRVs (3-4s apart) if left alive.
|
|
const simUfo = createGame(DEFAULT_CITIES, 51);
|
|
simUfo.wave = TUNE.UFO_WAVE;
|
|
simUfo.waveQuota = Infinity;
|
|
simUfo.nextUfoAt = 100;
|
|
for (let i = 0; i < 20 && !simUfo.ufo; i += 1) step(simUfo, 16);
|
|
check('UFO spawns once its timer elapses at/after UFO_WAVE', simUfo.ufo !== null);
|
|
|
|
if (simUfo.ufo) {
|
|
const { x, dir, speed } = simUfo.ufo;
|
|
check('UFO starts just off the correct edge for its direction',
|
|
(dir === 1 && x < 0) || (dir === -1 && x > WIDTH), `dir=${dir} x=${x}`);
|
|
check('UFO speed crosses the full width in exactly UFO_CROSS_MS',
|
|
Math.abs(speed * TUNE.UFO_CROSS_MS - WIDTH) < 1e-6);
|
|
check('UFO turn count is within UFO_TURNS range',
|
|
simUfo.ufo.turnSchedule.length >= TUNE.UFO_TURNS[0] && simUfo.ufo.turnSchedule.length <= TUNE.UFO_TURNS[1],
|
|
`turns=${simUfo.ufo.turnSchedule.length}`);
|
|
|
|
const maxVyAllowed = speed * Math.sin(TUNE.UFO_MAX_ANGLE_DEG * Math.PI / 180) + 1e-9;
|
|
let turnsSeen = 0;
|
|
let shots = 0;
|
|
const shotTimes = [];
|
|
let elapsed = 0;
|
|
let vyMaxSeen = 0;
|
|
for (let i = 0; i < 700 && simUfo.ufo; i += 1) {
|
|
const events = step(simUfo, 16);
|
|
elapsed += 16;
|
|
for (const ev of events) {
|
|
if (ev.type === 'ufoTurn') turnsSeen += 1;
|
|
if (ev.type === 'ufoShot') { shots += 1; shotTimes.push(elapsed); }
|
|
}
|
|
if (simUfo.ufo) vyMaxSeen = Math.max(vyMaxSeen, Math.abs(simUfo.ufo.vy));
|
|
}
|
|
check('UFO performs its scheduled direction changes',
|
|
turnsSeen >= TUNE.UFO_TURNS[0], `turnsSeen=${turnsSeen}`);
|
|
check('UFO never exceeds the ±UFO_MAX_ANGLE_DEG heading',
|
|
vyMaxSeen <= maxVyAllowed, `vyMaxSeen=${vyMaxSeen} allowed=${maxVyAllowed}`);
|
|
check('UFO fires exactly UFO_SHOTS MIRV shots if left alive', shots === TUNE.UFO_SHOTS, `shots=${shots}`);
|
|
if (shotTimes.length === 2) {
|
|
const gap = shotTimes[1] - shotTimes[0];
|
|
check('gap between the two shots is within the configured 3-4s window',
|
|
gap >= TUNE.UFO_SHOT_GAP_MIN_MS - 16 && gap <= TUNE.UFO_SHOT_GAP_MAX_MS + 16, `gap=${gap}`);
|
|
}
|
|
}
|
|
|
|
// Hit: any lethal explosion at the UFO's position destroys it, scores
|
|
// UFO_SCORE exactly, and spawns debris that keeps its current heading.
|
|
const simHit = createGame(DEFAULT_CITIES, 52);
|
|
simHit.wave = TUNE.UFO_WAVE;
|
|
simHit.waveQuota = Infinity;
|
|
const ufoY = HEIGHT * 0.15;
|
|
const ufoSpeed = WIDTH / TUNE.UFO_CROSS_MS;
|
|
simHit.ufo = {
|
|
id: simHit.nextId++, x: 600, y: ufoY, dir: 1, speed: ufoSpeed,
|
|
vx: ufoSpeed * 0.9, vy: ufoSpeed * 0.3, // mid-weave heading
|
|
elapsed: 0, turnSchedule: [], turnIdx: 0, shotsLeft: 0, nextShotAt: 999999, shotGap: 3500,
|
|
};
|
|
const ufoScoreBefore = simHit.score;
|
|
simHit.spawnExplosion(600, ufoY, 'player');
|
|
let ufoDestroyedEvent = null;
|
|
for (let i = 0; i < 50 && !ufoDestroyedEvent; i += 1) {
|
|
const events = step(simHit, 16);
|
|
ufoDestroyedEvent = events.find((e) => e.type === 'ufoDestroyed') || ufoDestroyedEvent;
|
|
}
|
|
check('a lethal explosion at the UFO destroys it', ufoDestroyedEvent !== null);
|
|
check('UFO kill awards exactly UFO_SCORE', simHit.score === ufoScoreBefore + TUNE.UFO_SCORE,
|
|
`before=${ufoScoreBefore} after=${simHit.score}`);
|
|
check('UFO is cleared after being destroyed', simHit.ufo === null);
|
|
const [uLo, uHi] = TUNE.UFO_DEBRIS_COUNT;
|
|
check('UFO debris count is within UFO_DEBRIS_COUNT range',
|
|
simHit.debris.length >= uLo && simHit.debris.length <= uHi, `count=${simHit.debris.length}`);
|
|
check("UFO debris keeps its current heading's momentum",
|
|
simHit.debris.length > 0 && simHit.debris.every((p) => p.vx > 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;
|
|
|
|
const planeList = sim.plane ? [sim.plane] : [];
|
|
for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions, planeList, sim.debris]) {
|
|
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);
|