651 lines
32 KiB
JavaScript
651 lines
32 KiB
JavaScript
// Headless verification for Geometry Wars.
|
||
// node tools/verifyGeometryWars.js
|
||
// Exits non-zero on any failure.
|
||
//
|
||
// 1. Setup per mode (lives, bombs, gun).
|
||
// 2. Movement: speed cap, diagonal normalisation, arena clamp.
|
||
// 3. Firing: volley size/rate per weapon tier; pacifism can't shoot.
|
||
// 4. Kill → score × multiplier, geoms drop, pickup raises multiplier + gun.
|
||
// 5. Splitter splits into three minis; bombs/deaths never split.
|
||
// 6. Snake: tail eats bullets, head dies, tail kills the player.
|
||
// 7. Black hole: dormant until shot, pulls + absorbs, overloads into protons.
|
||
// 8. Bomb: kills everything for no points, edge-triggered, consumes a bomb.
|
||
// 9. Death: board wipe, multiplier/gun reset, respawn invulnerability,
|
||
// game over when the last ship goes.
|
||
// 10. Extra life / bomb thresholds.
|
||
// 11. Pacifism gates: crossing detonates, posts kill, blast radius.
|
||
// 12. Spawning: nothing within the safe radius; warming-up enemies are inert.
|
||
// 13. Spiral-of-death guard.
|
||
// 14. Determinism.
|
||
// 15. Warp grid: settles back to rest, never NaNs.
|
||
// 16. Particle field: pool cap, NaN-free, hole capture.
|
||
// 17. Bot soak: many seeds, both modes, invariants every tick.
|
||
|
||
import {
|
||
STEP_MS, MAX_STEPS, ARENA_W, ARENA_H, TUNE, ENEMY_TYPES, WEAPONS,
|
||
createGame, setInput, step, spawnEnemy, snakeSegments, gateEndpoints,
|
||
weaponTierFor, runPattern, _internal,
|
||
} from '../src/games/geometrywars/GeometryWarsLogic.js';
|
||
import WarpGrid from '../src/games/geometrywars/GeometryWarsGrid.js';
|
||
import ParticleField, { FX_MAX } from '../src/games/geometrywars/GeometryWarsFx.js';
|
||
|
||
let failures = 0;
|
||
let passes = 0;
|
||
function check(name, cond, detail = '') {
|
||
if (cond) { passes += 1; console.log(` ok ${name}`); return; }
|
||
failures += 1;
|
||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
|
||
function runTicks(s, count, input = null) {
|
||
if (input) setInput(s, input);
|
||
const events = [];
|
||
for (let i = 0; i < count; i += 1) events.push(...step(s, STEP_MS));
|
||
return events;
|
||
}
|
||
|
||
// A quiet arena: director held off so tests control every spawn.
|
||
function quiet(mode = 'evolved', seed = 7) {
|
||
const s = createGame({ seed, mode });
|
||
s.director.spawnCd = 1e12;
|
||
s.director.gateCd = 1e12;
|
||
return s;
|
||
}
|
||
|
||
const ofType = (events, type) => events.filter((e) => e.type === type);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('1. Setup per mode');
|
||
{
|
||
const ev = createGame({ seed: 1, mode: 'evolved' });
|
||
check('evolved starts with 3 ships', ev.lives === 3);
|
||
check('evolved starts with 3 bombs', ev.bombs === 3);
|
||
check('evolved starts on the twin stream', ev.weaponTier === 0 && WEAPONS[0].shots.length === 2);
|
||
check('multiplier starts at 1', ev.multiplier === 1);
|
||
const pc = createGame({ seed: 1, mode: 'pacifism' });
|
||
check('pacifism is one ship', pc.lives === 1);
|
||
check('pacifism has no bombs', pc.bombs === 0);
|
||
check('unknown mode falls back to evolved', createGame({ mode: 'bogus' }).mode === 'evolved');
|
||
check('player starts mid-arena', ev.player.x === ARENA_W / 2 && ev.player.y === ARENA_H / 2);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('2. Movement');
|
||
{
|
||
const s = quiet();
|
||
runTicks(s, 90, { moveX: 1, moveY: 0 });
|
||
const sp = Math.hypot(s.player.vx, s.player.vy);
|
||
check('reaches but never exceeds top speed', sp <= TUNE.PLAYER_SPEED + 1e-6 && sp > TUNE.PLAYER_SPEED * 0.95, `speed ${sp.toFixed(1)}`);
|
||
const d = quiet();
|
||
runTicks(d, 90, { moveX: 1, moveY: 1 });
|
||
const dsp = Math.hypot(d.player.vx, d.player.vy);
|
||
check('diagonal is normalised (no √2 boost)', dsp <= TUNE.PLAYER_SPEED + 1e-6, `speed ${dsp.toFixed(1)}`);
|
||
const c = quiet();
|
||
runTicks(c, 400, { moveX: -1, moveY: -1 });
|
||
check('clamped inside arena (top-left)', c.player.x >= TUNE.PLAYER_RADIUS - 1e-9 && c.player.y >= TUNE.PLAYER_RADIUS - 1e-9);
|
||
runTicks(c, 600, { moveX: 1, moveY: 1 });
|
||
check('clamped inside arena (bottom-right)', c.player.x <= ARENA_W - TUNE.PLAYER_RADIUS + 1e-9 && c.player.y <= ARENA_H - TUNE.PLAYER_RADIUS + 1e-9);
|
||
const f = quiet();
|
||
runTicks(f, 60, { moveX: 0, moveY: 1 });
|
||
check('ship turns to face its travel direction', Math.abs(f.player.angle - Math.PI / 2) < 0.05, `angle ${f.player.angle.toFixed(3)}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('3. Firing');
|
||
{
|
||
for (let tier = 0; tier < WEAPONS.length; tier += 1) {
|
||
const s = quiet();
|
||
s.weaponTier = tier;
|
||
const ev = runTicks(s, 1, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
|
||
check(`tier ${tier} (${WEAPONS[tier].name}) fires ${WEAPONS[tier].shots.length} bullets`, s.bullets.length === WEAPONS[tier].shots.length && ofType(ev, 'shotFired').length === 1,
|
||
`got ${s.bullets.length}`);
|
||
}
|
||
const s = quiet();
|
||
const ev = runTicks(s, 60, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
|
||
const volleys = ofType(ev, 'shotFired').length;
|
||
const expected = Math.floor(1000 / WEAPONS[0].intervalMs) + 1;
|
||
check('fire rate matches the weapon interval', Math.abs(volleys - expected) <= 1, `${volleys} volleys/s, expected ~${expected}`);
|
||
const forward = s.bullets.every((b) => b.vx > 0 && Math.abs(b.vy) < Math.abs(b.vx) * 0.1);
|
||
check('bullets travel toward the aim point', forward);
|
||
const pc = quiet('pacifism');
|
||
runTicks(pc, 60, { fire: true, aimX: 0, aimY: 0 });
|
||
check('pacifism cannot shoot', pc.bullets.length === 0);
|
||
const wall = quiet();
|
||
const wev = runTicks(wall, 120, { fire: true, aimX: wall.player.x + 5000, aimY: wall.player.y });
|
||
check('bullets that reach a wall emit bulletWall', ofType(wev, 'bulletWall').length > 0);
|
||
check('no bullet survives outside the arena', wall.bullets.every((b) => b.x >= 0 && b.x <= ARENA_W && b.y >= 0 && b.y <= ARENA_H));
|
||
check('weaponTierFor thresholds', weaponTierFor(1) === 0 && weaponTierFor(10) === 1 && weaponTierFor(40) === 2 && weaponTierFor(100) === 3 && weaponTierFor(99999) === 3);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('4. Kills, geoms, multiplier');
|
||
{
|
||
const s = quiet();
|
||
s.multiplier = 3;
|
||
const g = spawnEnemy(s, 'grunt', s.player.x + 300, s.player.y, { spawnMs: 0 });
|
||
g.vx = 0;
|
||
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 300, aimY: s.player.y });
|
||
const kill = ofType(ev, 'enemyKilled')[0];
|
||
check('grunt killed by bullet', kill && kill.enemyType === 'grunt' && kill.cause === 'bullet');
|
||
check('points = base × multiplier', kill && kill.points === ENEMY_TYPES.grunt.score * 3, `points ${kill?.points}`);
|
||
check('score credited', s.score === ENEMY_TYPES.grunt.score * 3);
|
||
check('kill dropped geoms', s.geoms.length + s.stats.geoms >= ENEMY_TYPES.grunt.geoms);
|
||
|
||
const p = quiet();
|
||
for (let i = 0; i < 9; i += 1) p.geoms.push({ id: 1000 + i, x: p.player.x + 5, y: p.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
|
||
const pev = runTicks(p, 2);
|
||
check('touching geoms collects them', p.multiplier === 10 && p.stats.geoms === 9, `mult ${p.multiplier}`);
|
||
check('crossing ×10 upgrades the gun', p.weaponTier === 1 && ofType(pev, 'weaponUpgrade').length === 1);
|
||
check('milestone event at ×10', ofType(pev, 'multiplierMilestone').some((e) => e.multiplier === 10));
|
||
|
||
const m = quiet();
|
||
m.geoms.push({ id: 5, x: m.player.x + 120, y: m.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
|
||
runTicks(m, 40);
|
||
check('geoms within magnet range fly to the ship', m.stats.geoms === 1);
|
||
|
||
const e = quiet();
|
||
e.geoms.push({ id: 6, x: 100, y: 100, vx: 0, vy: 0, lifeMs: 500, spin: 0, dead: false });
|
||
runTicks(e, 40);
|
||
check('uncollected geoms expire', e.geoms.length === 0 && e.stats.geoms === 0);
|
||
|
||
const cap = quiet();
|
||
for (let i = 0; i < 200; i += 1) _internal.collectGeom(cap, { dead: false, x: 0, y: 0 });
|
||
check('peak multiplier tracked', cap.stats.peakMult === 201);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('5. Splitter');
|
||
{
|
||
const s = quiet();
|
||
const sp = spawnEnemy(s, 'splitter', s.player.x + 400, s.player.y, { spawnMs: 0 });
|
||
sp.vx = 0;
|
||
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
|
||
check('splitter dies to a bullet', ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'splitter'));
|
||
const minis = ofType(ev, 'enemySpawned').filter((k) => k.enemyType === 'mini');
|
||
check('splits into exactly three minis', minis.length === 3, `got ${minis.length}`);
|
||
const b = quiet();
|
||
spawnEnemy(b, 'splitter', 300, 300, { spawnMs: 0 });
|
||
_internal.detonateBomb(b);
|
||
runTicks(b, 1);
|
||
check('bombed splitter does not split', b.enemies.length === 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('6. Snake');
|
||
{
|
||
const s = quiet();
|
||
const sn = spawnEnemy(s, 'snake', s.player.x + 500, s.player.y, { spawnMs: 0, heading: 0 });
|
||
// Lay its tail out toward the player so shots hit tail before head.
|
||
sn.trail = sn.trail.map((_, i) => ({ x: sn.x - i * 6, y: sn.y }));
|
||
sn.heading = 0;
|
||
const segs = snakeSegments(sn);
|
||
check('snake exposes its segment count', segs.length === TUNE.SNAKE_SEGMENTS);
|
||
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 500, aimY: s.player.y });
|
||
check('tail absorbs bullets', ofType(ev, 'bulletDeflect').length > 0);
|
||
check('tail hits do not kill the snake', !ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
|
||
|
||
const h = quiet();
|
||
// Coming straight at the player, so the head leads and the tail trails away.
|
||
const head = spawnEnemy(h, 'snake', h.player.x + 300, h.player.y, { spawnMs: 0, heading: Math.PI });
|
||
head.trail = head.trail.map((_, i) => ({ x: head.x + i * 6, y: head.y }));
|
||
const hev = runTicks(h, 30, { fire: true, aimX: h.player.x + 300, aimY: h.player.y });
|
||
check('head shot kills the snake', ofType(hev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
|
||
|
||
const t = quiet();
|
||
const tk = spawnEnemy(t, 'snake', t.player.x + 600, t.player.y + 600, { spawnMs: 0 });
|
||
tk.trail = tk.trail.map(() => ({ x: t.player.x, y: t.player.y }));
|
||
tk.trail[0] = { x: tk.x, y: tk.y };
|
||
const save = TUNE.SNAKE_SEGMENTS;
|
||
_internal.tick(t);
|
||
check('touching the tail kills the player', !t.player.alive && t.stats.deaths === 1);
|
||
check('(segment count unchanged)', TUNE.SNAKE_SEGMENTS === save);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('7. Black hole');
|
||
{
|
||
const s = quiet();
|
||
const hole = spawnEnemy(s, 'blackhole', s.player.x + 500, s.player.y, { spawnMs: 0 });
|
||
const g = spawnEnemy(s, 'wanderer', hole.x, hole.y - 200, { spawnMs: 0 });
|
||
const gy0 = g.y;
|
||
runTicks(s, 20, { fire: false });
|
||
check('dormant hole does not pull', Math.abs(g.y - gy0) > 0 && !hole.active); // wanderer moves on its own
|
||
const ev = runTicks(s, 30, { fire: true, aimX: hole.x, aimY: hole.y });
|
||
check('first hit activates the hole', hole.active && ofType(ev, 'blackHoleActivated').length === 1);
|
||
check('hits drain hp', hole.hp < TUNE.BLACKHOLE_HP);
|
||
|
||
const p = quiet();
|
||
const h2 = spawnEnemy(p, 'blackhole', 1000, 600, { spawnMs: 0 });
|
||
h2.active = true;
|
||
const gr = spawnEnemy(p, 'grunt', 1000, 420, { spawnMs: 0 });
|
||
p.player.x = 200; p.player.y = 1200; // far away, so the grunt chases elsewhere
|
||
const d0 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
|
||
runTicks(p, 10);
|
||
const d1 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
|
||
check('active hole pulls enemies in', d1 < d0 || gr.dead, `${d0.toFixed(1)} → ${d1.toFixed(1)}`);
|
||
runTicks(p, 240);
|
||
check('hole absorbs what it pulls in (no points)', h2.absorbed >= 1 && p.score === 0);
|
||
|
||
const pp = quiet();
|
||
const h3 = spawnEnemy(pp, 'blackhole', pp.player.x + 250, pp.player.y, { spawnMs: 0 });
|
||
h3.active = true;
|
||
const px0 = pp.player.x;
|
||
runTicks(pp, 10, { moveX: 0, moveY: 0 });
|
||
check('active hole drags the player', pp.player.x > px0);
|
||
|
||
const o = quiet();
|
||
const h4 = spawnEnemy(o, 'blackhole', 1000, 600, { spawnMs: 0 });
|
||
h4.active = true;
|
||
o.player.x = 200; o.player.y = 200;
|
||
for (let i = 0; i < TUNE.BLACKHOLE_OVERLOAD; i += 1) spawnEnemy(o, 'wanderer', 1000 + 5, 600, { spawnMs: 0 });
|
||
const oev = runTicks(o, 3);
|
||
check('overfed hole overloads', ofType(oev, 'blackHoleOverload').length === 1 && !o.enemies.some((e) => e.type === 'blackhole'));
|
||
check('overload releases protons', o.enemies.filter((e) => e.type === 'proton').length >= 6);
|
||
|
||
const k = quiet();
|
||
const h5 = spawnEnemy(k, 'blackhole', k.player.x + 300, k.player.y, { spawnMs: 0 });
|
||
h5.active = true; h5.hp = 1; h5.absorbed = 4;
|
||
k.multiplier = 2;
|
||
const kev = runTicks(k, 30, { fire: true, aimX: h5.x, aimY: h5.y });
|
||
const kk = ofType(kev, 'enemyKilled').find((e) => e.enemyType === 'blackhole');
|
||
check('shot-down hole scores base + 50/absorbed × mult', kk && kk.points === (ENEMY_TYPES.blackhole.score + 200) * 2, `points ${kk?.points}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('8. Bomb');
|
||
{
|
||
const s = quiet();
|
||
for (let i = 0; i < 20; i += 1) spawnEnemy(s, 'grunt', 200 + i * 40, 200, { spawnMs: i % 2 ? 0 : 500 });
|
||
const ev = runTicks(s, 1, { bomb: true });
|
||
check('bomb clears every enemy (incl. warming up)', s.enemies.length === 0);
|
||
check('bomb kills score nothing', s.score === 0 && ofType(ev, 'enemyKilled').every((e) => e.points === 0));
|
||
check('bomb consumes one bomb', s.bombs === 2 && s.stats.bombsUsed === 1);
|
||
runTicks(s, 30, { bomb: true });
|
||
check('holding the button does not chain bombs', s.bombs === 2);
|
||
runTicks(s, 1, { bomb: false });
|
||
runTicks(s, 1, { bomb: true });
|
||
check('release + press fires the next bomb', s.bombs === 1);
|
||
check('bomb grants a spawn lull', s.director.spawnCd > 0);
|
||
const z = quiet();
|
||
z.bombs = 0;
|
||
spawnEnemy(z, 'grunt', 200, 200, { spawnMs: 0 });
|
||
runTicks(z, 1, { bomb: true });
|
||
check('no bombs → nothing happens', z.enemies.length === 1);
|
||
const pc = quiet('pacifism');
|
||
pc.bombs = 3;
|
||
spawnEnemy(pc, 'grunt', 200, 200, { spawnMs: 0 });
|
||
runTicks(pc, 1, { bomb: true });
|
||
check('pacifism never bombs', pc.enemies.length === 1 && pc.bombs === 3);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('9. Death and respawn');
|
||
{
|
||
const s = quiet();
|
||
s.multiplier = 55; s.weaponTier = 2; s.director.clockMs = 100000;
|
||
for (let i = 0; i < 10; i += 1) spawnEnemy(s, 'wanderer', 300 + i * 50, 300, { spawnMs: 0 });
|
||
s.geoms.push({ id: 9, x: 50, y: 50, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
|
||
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
|
||
const ev = runTicks(s, 1);
|
||
check('touching an enemy kills the player', ofType(ev, 'playerDied').length === 1 && !s.player.alive);
|
||
check('a ship is lost', s.lives === 2);
|
||
check('board wiped for no points', s.enemies.length === 0 && s.score === 0);
|
||
check('geoms cleared', s.geoms.length === 0);
|
||
check('multiplier and gun reset', s.multiplier === 1 && s.weaponTier === 0);
|
||
check('difficulty rewinds', s.director.clockMs < 100000);
|
||
const rev = runTicks(s, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 1);
|
||
check('respawns after the delay', s.player.alive && ofType(rev, 'playerRespawned').length === 1);
|
||
check('respawn is invulnerable', s.player.invulnMs > 0);
|
||
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
|
||
runTicks(s, 2);
|
||
check('invulnerable ship survives contact', s.player.alive);
|
||
|
||
const warm = quiet();
|
||
spawnEnemy(warm, 'grunt', warm.player.x, warm.player.y, { spawnMs: 400 });
|
||
runTicks(warm, 3);
|
||
check('warming-up enemies are harmless', warm.player.alive);
|
||
|
||
const last = quiet();
|
||
last.lives = 1;
|
||
spawnEnemy(last, 'grunt', last.player.x, last.player.y, { spawnMs: 0 });
|
||
runTicks(last, 1);
|
||
check('last ship lost → no respawn', !last.player.alive && last.lives === 0);
|
||
const gev = runTicks(last, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 2);
|
||
const go = ofType(gev, 'gameOver');
|
||
check('game over emitted exactly once', go.length === 1 && last.phase === 'over');
|
||
check('game over carries score + stats', go[0] && go[0].score === last.score && go[0].stats.deaths === 1);
|
||
check('a finished game stays finished', runTicks(last, 60).length === 0 && last.phase === 'over');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('10. Extra lives and bombs');
|
||
{
|
||
const s = quiet();
|
||
s.score = TUNE.FIRST_LIFE_AT;
|
||
const ev = runTicks(s, 1);
|
||
check('first life threshold awards a ship', s.lives === 4 && ofType(ev, 'extraLife').length === 1);
|
||
check('next life threshold grows', s.nextLifeAt === Math.round(TUNE.FIRST_LIFE_AT * TUNE.THRESHOLD_GROWTH));
|
||
s.score = TUNE.FIRST_BOMB_AT;
|
||
const bev = runTicks(s, 1);
|
||
check('bomb threshold awards a bomb', s.bombs === 4 && ofType(bev, 'extraBomb').length === 1);
|
||
const big = quiet();
|
||
big.score = 1e12;
|
||
runTicks(big, 1);
|
||
check('lives capped', big.lives === TUNE.MAX_LIVES);
|
||
check('bombs capped', big.bombs === TUNE.MAX_BOMBS);
|
||
check('thresholds advanced past the score', big.nextLifeAt > big.score && big.nextBombAt > big.score);
|
||
const pc = quiet('pacifism');
|
||
pc.score = 1e9;
|
||
runTicks(pc, 1);
|
||
check('pacifism awards no extra ships', pc.lives === 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('11. Pacifism gates');
|
||
{
|
||
const s = quiet('pacifism');
|
||
const g = _internal.spawnGate(s);
|
||
check('gate spawns', !!g && s.gates.length === 1);
|
||
g.spawnMs = 0; g.vx = 0; g.vy = 0; g.spinRate = 0; g.angle = Math.PI / 2; // vertical
|
||
g.x = 1000; g.y = 600;
|
||
s.player.x = 900; s.player.y = 600;
|
||
s.multiplier = 2;
|
||
const near = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R - 40, { spawnMs: 0 });
|
||
const far = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R + 200, { spawnMs: 0 });
|
||
near.vx = 0; far.vx = 0;
|
||
const ev = runTicks(s, 30, { moveX: 1, moveY: 0 });
|
||
check('flying through the middle detonates the gate', ofType(ev, 'gateTriggered').length === 1 && s.gates.length === 0);
|
||
check('blast kills enemies inside its radius', near.dead && ofType(ev, 'enemyKilled').some((k) => k.id === near.id && k.cause === 'gate'));
|
||
check('blast spares enemies outside it', !far.dead);
|
||
check('gate kills score × multiplier', s.score === ENEMY_TYPES.grunt.score * 2, `score ${s.score}`);
|
||
check('player survives a clean pass', s.player.alive);
|
||
check('gates counted', s.stats.gates === 1);
|
||
|
||
const p = quiet('pacifism');
|
||
const g2 = _internal.spawnGate(p);
|
||
g2.spawnMs = 0; g2.vx = 0; g2.vy = 0; g2.spinRate = 0; g2.angle = 0;
|
||
g2.x = 1000; g2.y = 600;
|
||
const [a] = gateEndpoints(g2);
|
||
p.player.x = a.x; p.player.y = a.y - 4;
|
||
runTicks(p, 1);
|
||
check('touching a gate post kills', !p.player.alive);
|
||
|
||
const d = createGame({ seed: 3, mode: 'pacifism' });
|
||
d.player.invulnMs = 1e12;
|
||
runTicks(d, 60 * 20);
|
||
check('director keeps gates on the field', d.stats.gates > 0 || d.gates.length >= 3, `gates ${d.gates.length}`);
|
||
check('pacifism director spawns enemies', d.enemies.length > 0 || d.stats.deaths > 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('12. Spawning');
|
||
{
|
||
let tooClose = 0; let total = 0;
|
||
for (const seed of [1, 2, 3, 4, 5, 6]) {
|
||
const s = createGame({ seed });
|
||
s.player.invulnMs = 1e12;
|
||
s.director.clockMs = 200000; // everything unlocked
|
||
for (let i = 0; i < 1500; i += 1) {
|
||
s.player.x = 300 + ((i * 37) % (ARENA_W - 600));
|
||
s.player.y = 300 + ((i * 53) % (ARENA_H - 600));
|
||
const ev = step(s, STEP_MS);
|
||
for (const e of ev) {
|
||
if (e.type !== 'enemySpawned' || e.enemyType === 'mini' || e.enemyType === 'proton') continue;
|
||
total += 1;
|
||
if (Math.hypot(e.x - s.player.x, e.y - s.player.y) < TUNE.SPAWN_SAFE_DIST - 1) tooClose += 1;
|
||
}
|
||
s.enemies.length = Math.min(s.enemies.length, 120);
|
||
}
|
||
}
|
||
check('director spawns plenty', total > 500, `spawned ${total}`);
|
||
check('nothing spawns inside the safe radius', tooClose === 0, `${tooClose}/${total} too close`);
|
||
for (const pattern of ['scatter', 'corners', 'swarm', 'ring', 'rocketwall', 'blackhole', 'snakes']) {
|
||
const s = quiet('evolved', 11);
|
||
s.director.clockMs = 200000;
|
||
runPattern(s, pattern);
|
||
const inside = s.enemies.every((e) => e.x >= e.radius - 1e-6 && e.x <= ARENA_W - e.radius + 1e-6 && e.y >= e.radius - 1e-6 && e.y <= ARENA_H - e.radius + 1e-6);
|
||
check(`pattern '${pattern}' spawns inside the arena`, s.enemies.length > 0 && inside, `count ${s.enemies.length}`);
|
||
}
|
||
const cap = quiet();
|
||
for (let i = 0; i < 400; i += 1) spawnEnemy(cap, 'grunt', 100, 100);
|
||
check('enemy cap respected', cap.enemies.length === TUNE.MAX_ENEMIES);
|
||
const bh = quiet();
|
||
bh.director.clockMs = 200000;
|
||
for (let i = 0; i < 10; i += 1) runPattern(bh, 'blackhole');
|
||
check('black hole count capped', bh.enemies.filter((e) => e.type === 'blackhole').length <= TUNE.BLACKHOLE_MAX);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('13. Spiral-of-death guard');
|
||
{
|
||
const s = quiet();
|
||
const t0 = s.tick;
|
||
step(s, 10_000);
|
||
check('huge delta runs at most MAX_STEPS ticks', s.tick - t0 === MAX_STEPS, `ran ${s.tick - t0}`);
|
||
const u = quiet();
|
||
step(u, -50); step(u, NaN);
|
||
check('negative/NaN deltas are ignored', u.tick === 0 && u.acc === 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('14. Determinism');
|
||
{
|
||
const run = () => {
|
||
const s = createGame({ seed: 4242 });
|
||
let sig = 0;
|
||
for (let i = 0; i < 60 * 90; i += 1) {
|
||
const a = i * 0.013;
|
||
setInput(s, {
|
||
moveX: Math.cos(a), moveY: Math.sin(a * 1.3),
|
||
aimX: s.player.x + Math.cos(a * 3) * 300, aimY: s.player.y + Math.sin(a * 3) * 300,
|
||
fire: true, bomb: i % 900 === 0,
|
||
});
|
||
const ev = step(s, STEP_MS * (1 + (i % 3) * 0.37));
|
||
sig = (sig * 31 + ev.length + s.enemies.length * 7 + s.score) % 1e15;
|
||
}
|
||
return { sig, score: s.score, id: s.nextId, x: s.player.x, mult: s.multiplier, deaths: s.stats.deaths };
|
||
};
|
||
const a = run(); const b = run();
|
||
check('same seed + inputs → identical run', JSON.stringify(a) === JSON.stringify(b), `${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
|
||
const c = createGame({ seed: 1 }); const d = createGame({ seed: 2 });
|
||
for (let i = 0; i < 600; i += 1) { step(c, STEP_MS); step(d, STEP_MS); }
|
||
check('different seeds diverge', JSON.stringify(c.enemies.map((e) => [e.x, e.y])) !== JSON.stringify(d.enemies.map((e) => [e.x, e.y])));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('15. Warp grid');
|
||
{
|
||
const g = new WarpGrid(ARENA_W, ARENA_H, 40);
|
||
for (let i = 0; i < 30; i += 1) g.update();
|
||
check('grid starts at rest', g.maxDisplacement() < 1e-6);
|
||
g.applyExplosiveForce(40, 1000, 600, 900);
|
||
g.applyDirectedForce(0, 0, 150, 1000, 600, 300);
|
||
for (let i = 0; i < 300; i += 1) g.applyImplosiveForce(4, 1500, 800, 240), g.update();
|
||
const disp = g.maxDisplacement();
|
||
check('forces displace the grid', disp > 5, `disp ${disp.toFixed(2)}`);
|
||
let finite = true;
|
||
for (let i = 0; i < g.n; i += 1) if (!Number.isFinite(g.px[i]) || !Number.isFinite(g.py[i]) || !Number.isFinite(g.pz[i])) finite = false;
|
||
check('grid stays finite under load', finite);
|
||
check('grid stays bounded under a sustained hole', disp < 400, `disp ${disp.toFixed(1)}`);
|
||
for (let i = 0; i < 60 * 30; i += 1) g.update();
|
||
check('grid settles back to rest', g.maxDisplacement() < 1.5, `disp ${g.maxDisplacement().toFixed(3)}`);
|
||
let pinned = true;
|
||
for (let r = 0; r < g.rows; r += 1) for (let c = 0; c < g.cols; c += 1) {
|
||
if (r !== 0 && c !== 0 && r !== g.rows - 1 && c !== g.cols - 1) continue;
|
||
const i = r * g.cols + c;
|
||
if (g.px[i] !== g.ox[i] || g.py[i] !== g.oy[i]) pinned = false;
|
||
}
|
||
check('border masses stay pinned', pinned);
|
||
// Draw against a recording stub: every coordinate must be finite.
|
||
const calls = { n: 0, bad: 0 };
|
||
const stub = {
|
||
lineStyle() {}, beginPath() {}, strokePath() {},
|
||
moveTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
|
||
lineTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
|
||
};
|
||
g.applyExplosiveForce(30, 600, 400, 300);
|
||
g.update();
|
||
g.draw(stub, { x: 160, y: 100, w: 1920, h: 1080 }, { minor: 1, minorAlpha: 1, minorWidth: 1, major: 1, majorAlpha: 1, majorWidth: 2, hot: 1, hotAlpha: 1, hotter: 1, hotterAlpha: 1 });
|
||
check('grid draw emits finite coordinates', calls.n > 1000 && calls.bad === 0, `${calls.n} calls, ${calls.bad} bad`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('16. Particle field');
|
||
{
|
||
const fx = new ParticleField({ w: ARENA_W, h: ARENA_H });
|
||
for (let i = 0; i < 20; i += 1) fx.explosion(1000, 600, 0xff00ff, { count: 300 });
|
||
check('pool never exceeds FX_MAX', fx.liveCount() <= FX_MAX);
|
||
for (let i = 0; i < 30; i += 1) fx.update(16.7, [{ x: 1100, y: 600, r: 26 }]);
|
||
let ok = true;
|
||
for (let i = 0; i < FX_MAX; i += 1) {
|
||
if (fx.life[i] <= 0) continue;
|
||
if (!Number.isFinite(fx.x[i]) || !Number.isFinite(fx.y[i])) ok = false;
|
||
if (fx.x[i] < -1 || fx.x[i] > ARENA_W + 1 || fx.y[i] < -1 || fx.y[i] > ARENA_H + 1) ok = false;
|
||
}
|
||
check('particles stay finite and inside the arena', ok);
|
||
const before = fx.liveCount();
|
||
for (let i = 0; i < 200; i += 1) fx.update(16.7, [{ x: 1000, y: 600, r: 60 }]);
|
||
check('particles expire', fx.liveCount() < before);
|
||
fx.ring(0, 0, 100, 300, 0xffffff);
|
||
fx.update(400);
|
||
check('rings expire', fx.rings.length === 0);
|
||
fx.wallSparks(0, 500, Math.PI, 0xffff00, 10);
|
||
fx.update(16.7);
|
||
let rightward = 0;
|
||
for (let i = 0; i < FX_MAX; i += 1) if (fx.life[i] > 0 && fx.vx[i] > 0) rightward += 1;
|
||
check('wall sparks bounce back into the arena', rightward >= 8);
|
||
const calls = { bad: 0 };
|
||
const stub = {
|
||
lineStyle() {}, beginPath() {}, strokePath() {}, strokeCircle() {},
|
||
moveTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
|
||
lineTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
|
||
};
|
||
fx.explosion(500, 500, 0x00ffff, { count: 100 });
|
||
fx.draw(stub, { x: 0, y: 0, w: 1920, h: 1080 });
|
||
check('particle draw emits finite coordinates', calls.bad === 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
console.log('17. Bot soak');
|
||
{
|
||
// A dodging bot with human-ish limits: it only re-decides every `latency`
|
||
// ticks, aims with deterministic jitter, and only reacts to enemies inside
|
||
// `react` px. It aims at the nearest enemy, strafes away from anything
|
||
// close and bombs when crowded. Two profiles bracket the difficulty curve;
|
||
// invariants are checked every tick of every run.
|
||
const PROFILES = {
|
||
sharp: { latency: 10, noise: 0.15, react: 260 },
|
||
sloppy: { latency: 18, noise: 0.2, react: 220 },
|
||
};
|
||
function makeBot(profile, seed) {
|
||
let r = seed * 7919 + 13;
|
||
const jitter = () => { r = (r * 1103515245 + 12345) % 2147483648; return r / 2147483648 - 0.5; };
|
||
let held = null; let tick = 0;
|
||
return (s) => {
|
||
tick += 1;
|
||
if (held && tick % profile.latency !== 0) return held;
|
||
const p = s.player;
|
||
let nearest = null; let nd = Infinity;
|
||
let ax = 0; let ay = 0;
|
||
for (const e of s.enemies) {
|
||
const dx = e.x - p.x; const dy = e.y - p.y;
|
||
const d = Math.hypot(dx, dy) || 1;
|
||
if (d < nd && e.spawnMs <= 0) { nd = d; nearest = e; }
|
||
if (d < profile.react) { ax -= dx / d * (profile.react - d); ay -= dy / d * (profile.react - d); }
|
||
}
|
||
for (const g of s.gates) {
|
||
const dx = g.x - p.x; const dy = g.y - p.y;
|
||
const d = Math.hypot(dx, dy) || 1;
|
||
ax += dx / d * 120; ay += dy / d * 120;
|
||
for (const end of gateEndpoints(g)) {
|
||
const ex = end.x - p.x; const ey = end.y - p.y;
|
||
const ed = Math.hypot(ex, ey) || 1;
|
||
if (ed < 90) { ax -= ex / ed * (90 - ed) * 12; ay -= ey / ed * (90 - ed) * 12; }
|
||
}
|
||
}
|
||
ax += (ARENA_W / 2 - p.x) * 0.08; ay += (ARENA_H / 2 - p.y) * 0.08;
|
||
const close = s.enemies.filter((e) => Math.hypot(e.x - p.x, e.y - p.y) < 140).length;
|
||
const aim = (nearest ? Math.atan2(nearest.y - p.y, nearest.x - p.x) : 0) + jitter() * 2 * profile.noise;
|
||
held = {
|
||
moveX: Math.sign(ax) * Math.min(1, Math.abs(ax) / 50),
|
||
moveY: Math.sign(ay) * Math.min(1, Math.abs(ay) / 50),
|
||
aimX: p.x + Math.cos(aim) * 300, aimY: p.y + Math.sin(aim) * 300,
|
||
fire: true,
|
||
bomb: close >= 4,
|
||
};
|
||
return held;
|
||
};
|
||
}
|
||
|
||
const runs = [
|
||
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sharp', seed })),
|
||
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sloppy', seed })),
|
||
...[11, 22, 33, 44].map((seed) => ({ mode: 'pacifism', profile: 'sharp', seed })),
|
||
];
|
||
const results = [];
|
||
let violations = [];
|
||
const seenTypes = new Set();
|
||
for (const { mode, profile, seed } of runs) {
|
||
const s = createGame({ seed, mode });
|
||
const bot = makeBot(PROFILES[profile], seed);
|
||
let lastScore = 0;
|
||
let firstDeathMs = null;
|
||
const limit = 60 * 60 * 8; // 8 simulated minutes
|
||
let ticks = 0;
|
||
const tag = `${mode}/${profile}/${seed}`;
|
||
while (s.phase !== 'over' && ticks < limit) {
|
||
setInput(s, bot(s));
|
||
const ev = step(s, STEP_MS);
|
||
ticks += 1;
|
||
if (firstDeathMs == null && ev.some((e) => e.type === 'playerDied')) firstDeathMs = s.timeMs;
|
||
if (s.score < lastScore) violations.push(`${tag}: score went down`);
|
||
lastScore = s.score;
|
||
if (s.enemies.length > TUNE.MAX_ENEMIES + 40) violations.push(`${tag}: enemy overflow ${s.enemies.length}`);
|
||
if (s.bullets.length > TUNE.MAX_BULLETS) violations.push(`${tag}: bullet overflow`);
|
||
if (s.geoms.length > TUNE.MAX_GEOMS) violations.push(`${tag}: geom overflow`);
|
||
if (s.multiplier < 1 || s.lives < 0 || s.bombs < 0) violations.push(`${tag}: negative counters`);
|
||
if (mode === 'pacifism' && s.bullets.length) violations.push(`${tag}: pacifism bullet`);
|
||
if (ticks % 30 === 0) {
|
||
for (const e of s.enemies) {
|
||
seenTypes.add(e.type);
|
||
if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) violations.push(`${tag}: NaN ${e.type}`);
|
||
if (e.x < -1 || e.x > ARENA_W + 1 || e.y < -1 || e.y > ARENA_H + 1) violations.push(`${tag}: ${e.type} escaped arena`);
|
||
}
|
||
const p = s.player;
|
||
if (!Number.isFinite(p.x) || p.x < 0 || p.x > ARENA_W || p.y < 0 || p.y > ARENA_H) violations.push(`${tag}: player escaped`);
|
||
if (s.enemies.some((e) => e.dead) || s.bullets.some((b) => b.dead)) violations.push(`${tag}: dead entity kept`);
|
||
}
|
||
if (violations.length > 20) break;
|
||
}
|
||
results.push({
|
||
mode, profile, seed, over: s.phase === 'over', mins: s.timeMs / 60000,
|
||
firstDeath: firstDeathMs == null ? null : firstDeathMs / 60000,
|
||
score: s.score, kills: s.stats.kills, peak: s.stats.peakMult, deaths: s.stats.deaths, gates: s.stats.gates,
|
||
});
|
||
}
|
||
violations = [...new Set(violations)];
|
||
for (const r of results) {
|
||
console.log(` ${r.mode.padEnd(8)} ${r.profile.padEnd(6)} seed ${String(r.seed).padStart(2)}: ${r.over ? 'over ' : 'alive'} at ${r.mins.toFixed(1)}m`
|
||
+ `, first death ${r.firstDeath == null ? '—' : `${r.firstDeath.toFixed(1)}m`}, deaths ${r.deaths}, score ${formatInt(r.score)}, peak ×${r.peak}`
|
||
+ `${r.mode === 'pacifism' ? `, gates ${r.gates}` : ''}`);
|
||
}
|
||
const median = (xs) => { const m = [...xs].sort((a, b) => a - b); return m[Math.floor(m.length / 2)]; };
|
||
const sharp = results.filter((r) => r.mode === 'evolved' && r.profile === 'sharp');
|
||
const sloppy = results.filter((r) => r.mode === 'evolved' && r.profile === 'sloppy');
|
||
check('no invariant violations across the soak', violations.length === 0, violations.slice(0, 6).join('; '));
|
||
check('every run terminates or hits the time limit', results.length === runs.length);
|
||
check('evolved bots score', [...sharp, ...sloppy].every((r) => r.score > 0));
|
||
check('pacifism bot detonates gates', results.filter((r) => r.mode === 'pacifism').some((r) => r.gates > 0));
|
||
const allTypes = ['wanderer', 'grunt', 'weaver', 'splitter', 'snake', 'blackhole', 'rocket'];
|
||
check('every evolved enemy type appears in play', allTypes.every((t) => seenTypes.has(t)), `missing: ${allTypes.filter((t) => !seenTypes.has(t)).join(',')}`);
|
||
check('not too hard: sharp bot median run ≥ 3 min', median(sharp.map((r) => r.mins)) >= 3, `median ${median(sharp.map((r) => r.mins)).toFixed(2)}m`);
|
||
check('not too easy: sloppy bot loses a ship in most runs', sloppy.filter((r) => r.deaths > 0).length >= 3);
|
||
check('not too easy: sloppy bot first death before 4 min (median)',
|
||
median(sloppy.map((r) => r.firstDeath ?? 99)) < 4, `median ${median(sloppy.map((r) => r.firstDeath ?? 99)).toFixed(2)}m`);
|
||
}
|
||
|
||
function formatInt(n) { return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||
|
||
console.log(`\n${passes} passed, ${failures} failed`);
|
||
process.exit(failures ? 1 : 0);
|