fertig-classic-games/tools/verifyGeometryWars.js

651 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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