// 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);