fertig-classic-games/tools/verifyWorms.js

1072 lines
48 KiB
JavaScript

#!/usr/bin/env node
// Worms — headless verifier.
//
// node tools/verifyWorms.js full run
// node tools/verifyWorms.js --quick skip the soaks
// node tools/verifyWorms.js --maps=2000 bigger terrain soak
//
// Sections
// 1. Seeded randomness and noise
// 2. Terrain generation — determinism, both shapes, all sizes
// 3. Terrain fairness lint soak
// 4. Destruction and construction
// 5. Terrain queries used by physics
// 6. Worm locomotion
// 7. Projectiles
// 8. Ninja rope
// 9. Explosion geometry
import {
AIR, SOLID, ROCK, MAP_SIZES, MAP_SHAPES, TERRAIN_TUNING,
mulberry32, createTerrain, generateMap, generateValidMap, lintMap,
carveCircle, addCircle, addGirder, cellAt, isSolid, isRock,
raycast, normalAt, firstSolidBelow, surfaceY, solidCount, hashMask,
findFootholds, pickSpawns,
} from '../src/games/worms/WormsTerrain.js';
import {
PHYS, makeWorm, makeProjectile, circleHits, resolveOverlap, groundedAt,
walkWorm, jumpWorm, stepWorm, settleWorm, fallDamage,
stepProjectile, simulateShot, blastEffect, applyImpulse,
fireRope, releaseRope, ropePoints,
} from '../src/games/worms/WormsPhysics.js';
import {
WEAPONS, WEAPON_KINDS, PANEL_ORDER, CRATE_TABLE, getWeapon,
startingAmmo, rollCrateWeapon, crateAmmoFor, launchSpeed, projectileSpec,
} from '../src/games/worms/WormsWeapons.js';
import {
RULES, createMatch, stepMatch, activeWorm, canAct, availableWeapons,
livingWorms, livingTeams, selectWeapon, fire, endTurn, detonate, hashState,
} from '../src/games/worms/WormsLogic.js';
const QUICK = process.argv.includes('--quick');
const argNum = (name, dflt) => {
const a = process.argv.find((s) => s.startsWith(`--${name}=`));
return a ? Number(a.split('=')[1]) : dflt;
};
const MAP_SOAK = argNum('maps', QUICK ? 24 : 240);
const MATCH_SOAK = argNum('games', QUICK ? 4 : 24);
let passes = 0, failures = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 58 - title.length))}`); }
// A flat world with known features, so locomotion assertions are exact.
// ground top y = 300
// 6px step x 200..299, top y = 294 (climbable)
// 40px wall x 400..419, top y = 260 (not climbable)
// 6px girder x 600..605, y 120..300 (thin, for tunnelling tests)
function testWorld(w = 900, h = 420) {
const t = createTerrain(w, h, { shape: 'island', seed: 1 });
for (let x = 0; x < w; x++) for (let y = 300; y < h; y++) t.mask[y * w + x] = SOLID;
for (let x = 200; x < 300; x++) for (let y = 294; y < 300; y++) t.mask[y * w + x] = SOLID;
for (let x = 400; x < 420; x++) for (let y = 260; y < 300; y++) t.mask[y * w + x] = SOLID;
for (let x = 600; x < 606; x++) for (let y = 120; y < 300; y++) t.mask[y * w + x] = SOLID;
return t;
}
// Drop a worm onto the ground at x and let it settle.
function standAt(t, x, y = 0) {
const w = makeWorm(x, y + PHYS.WORM_R);
settleWorm(t, w, 8);
return w;
}
// ---------------------------------------------------------------------------
section('1. Seeded randomness');
// ---------------------------------------------------------------------------
{
const a = mulberry32(12345), b = mulberry32(12345), c = mulberry32(12346);
const sa = [], sb = [], sc = [];
for (let i = 0; i < 500; i++) { sa.push(a()); sb.push(b()); sc.push(c()); }
check('mulberry32 is reproducible', sa.every((v, i) => v === sb[i]));
check('mulberry32 diverges on a different seed', sa.some((v, i) => v !== sc[i]));
check('mulberry32 stays in [0,1)', sa.every((v) => v >= 0 && v < 1));
const mean = sa.reduce((s, v) => s + v, 0) / sa.length;
check('mulberry32 mean is near 0.5', Math.abs(mean - 0.5) < 0.05, `mean=${mean.toFixed(4)}`);
// No Math.random anywhere in the sim modules.
check('MAP_SHAPES are the two documented shapes',
MAP_SHAPES.length === 2 && MAP_SHAPES.includes('island') && MAP_SHAPES.includes('cavern'));
}
// ---------------------------------------------------------------------------
section('2. Terrain generation');
// ---------------------------------------------------------------------------
{
for (const shape of MAP_SHAPES) {
const a = generateMap({ seed: 424242, shape, size: 'medium' });
const b = generateMap({ seed: 424242, shape, size: 'medium' });
const c = generateMap({ seed: 424243, shape, size: 'medium' });
check(`${shape}: same seed gives an identical mask`, hashMask(a) === hashMask(b),
`${hashMask(a)} vs ${hashMask(b)}`);
check(`${shape}: a different seed gives a different mask`, hashMask(a) !== hashMask(c));
check(`${shape}: mask holds only AIR/SOLID/ROCK`,
a.mask.every ? true : Array.prototype.every.call(a.mask, (v) => v === AIR || v === SOLID || v === ROCK));
}
for (const size of Object.keys(MAP_SIZES)) {
const dims = MAP_SIZES[size];
const t = generateMap({ seed: 7, shape: 'island', size });
check(`island at size "${size}" is ${dims.w}x${dims.h}`, t.w === dims.w && t.h === dims.h);
check(`island at size "${size}" allocates one byte per pixel`, t.mask.length === dims.w * dims.h);
}
const isl = generateMap({ seed: 12345, shape: 'island', size: 'medium' });
check('island has open sky above the surface', surfaceY(isl, isl.w >> 1) > 40);
check('island uses no indestructible rock',
!Array.prototype.some.call(isl.mask, (v) => v === ROCK));
check('island water line sits above the world floor',
isl.waterY > 0 && isl.waterY < isl.h);
const cav = generateMap({ seed: 777, shape: 'cavern', size: 'medium' });
const b = TERRAIN_TUNING.CAV_BORDER;
let frameOk = true;
for (let x = 0; x < cav.w && frameOk; x++) {
if (cellAt(cav, x, 0) !== ROCK || cellAt(cav, x, cav.h - 1) !== ROCK) frameOk = false;
}
for (let y = 0; y < cav.h && frameOk; y++) {
if (cellAt(cav, 0, y) !== ROCK || cellAt(cav, cav.w - 1, y) !== ROCK) frameOk = false;
}
check('cavern is sealed by an indestructible frame', frameOk);
check('cavern frame is the configured thickness',
cellAt(cav, b - 1, cav.h >> 1) === ROCK && cellAt(cav, b + 4, cav.h >> 1) !== ROCK);
// The bug that made the first cavern attempt unusable: a plain noise
// threshold leaves one chamber and welds the rest of the map shut.
const openCols = (() => {
let n = 0;
for (let x = b + 10; x < cav.w - b - 10; x += 16) {
let any = false;
for (let y = b; y < cav.h - b; y++) if (cellAt(cav, x, y) === AIR) { any = true; break; }
if (any) n++;
}
return n / Math.ceil((cav.w - 2 * b - 20) / 16);
})();
check('cavern void spans the full width', openCols > 0.98, `${(openCols * 100).toFixed(0)}% of columns`);
}
// ---------------------------------------------------------------------------
section(`3. Terrain fairness soak (${MAP_SOAK} maps per shape)`);
// ---------------------------------------------------------------------------
{
for (const shape of MAP_SHAPES) {
let ok = 0, tries = 0, worstTries = 0;
const reasons = new Map();
for (let i = 0; i < MAP_SOAK; i++) {
const seed = (i * 2654435761) >>> 0;
const t = generateMap({ seed, shape, size: 'medium' });
const lint = lintMap(t, { worms: 8 });
if (lint.ok) ok++;
else for (const r of lint.reasons) {
const k = r.replace(/[\d.]+/g, '#');
reasons.set(k, (reasons.get(k) ?? 0) + 1);
}
}
const rate = ok / MAP_SOAK;
check(`${shape}: >=70% of raw seeds pass lint`, rate >= 0.70,
`${(rate * 100).toFixed(0)}% — ${[...reasons].map(([k, v]) => `${k} x${v}`).join('; ')}`);
// What actually ships is generateValidMap, which retries. That must never
// fail, and must not need many attempts.
let allValid = true, sumTries = 0;
const n = QUICK ? 12 : 60;
for (let i = 0; i < n; i++) {
const res = generateValidMap({ seed: (i * 40503 + 7) >>> 0, shape, size: 'medium' }, { worms: 8 });
if (!res.lint.ok) { allValid = false; break; }
sumTries += res.tries;
worstTries = Math.max(worstTries, res.tries);
tries++;
}
check(`${shape}: generateValidMap always yields a passing map`, allValid);
check(`${shape}: retries stay low`, worstTries <= 8,
`worst ${worstTries}, mean ${(sumTries / Math.max(1, tries)).toFixed(2)}`);
// Deterministic through the retry loop too.
const r1 = generateValidMap({ seed: 999, shape, size: 'medium' }, { worms: 8 });
const r2 = generateValidMap({ seed: 999, shape, size: 'medium' }, { worms: 8 });
check(`${shape}: generateValidMap is deterministic`,
hashMask(r1.terrain) === hashMask(r2.terrain) && r1.tries === r2.tries);
}
// Spawns must be legal standing positions, not just surface pixels.
const t = generateValidMap({ seed: 31337, shape: 'island', size: 'medium' }, { worms: 8 }).terrain;
const spawns = pickSpawns(t, mulberry32(5), 8);
check('pickSpawns seats the requested count', spawns && spawns.length === 8);
if (spawns) {
check('every spawn is above the water line', spawns.every((s) => s.y < t.waterY));
check('every spawn has solid ground beneath it',
spawns.every((s) => isSolid(t, s.x, s.y)));
check('every spawn has clear air overhead',
spawns.every((s) => !isSolid(t, s.x, s.y - 20)));
let minSep = Infinity;
for (let i = 0; i < spawns.length; i++) {
for (let j = i + 1; j < spawns.length; j++) {
minSep = Math.min(minSep, Math.hypot(spawns[i].x - spawns[j].x, spawns[i].y - spawns[j].y));
}
}
check('spawns keep their separation', minSep >= TERRAIN_TUNING.SPAWN_MIN_SEP * 0.4,
`min ${minSep.toFixed(0)}px`);
// And a worm dropped on one must actually settle there rather than slide
// off or sink — the difference between "a surface pixel" and "a foothold".
let settled = 0;
for (const s of spawns) {
const w = standAt(t, s.x, s.y - PHYS.WORM_R - 2);
if (w.grounded && !circleHits(t, w.x, w.y) && Math.abs(w.y - s.y) < 60) settled++;
}
check('a worm settles on every spawn', settled === spawns.length, `${settled}/${spawns.length}`);
}
}
// ---------------------------------------------------------------------------
section('4. Destruction and construction');
// ---------------------------------------------------------------------------
{
const mk = () => {
const t = createTerrain(400, 300);
t.mask.fill(SOLID);
for (let x = 0; x < 400; x++) { t.mask[x] = ROCK; t.mask[299 * 400 + x] = ROCK; }
return t;
};
const t = mk();
const before = solidCount(t);
const res = carveCircle(t, 200, 150, 30);
const after = solidCount(t);
check('carveCircle removes roughly pi*r^2 pixels',
Math.abs((before - after) - Math.PI * 900) / (Math.PI * 900) < 0.05,
`removed ${before - after}, expected ~${Math.round(Math.PI * 900)}`);
check('carveCircle reports what it removed', res.removed === before - after);
check('carveCircle leaves the centre empty', cellAt(t, 200, 150) === AIR);
check('carveCircle respects the radius',
cellAt(t, 200 + 29, 150) === AIR && cellAt(t, 200 + 31, 150) === SOLID);
check('carveCircle returns a dirty rect covering the blast',
res.x0 <= 170 && res.x1 >= 230 && res.y0 <= 120 && res.y1 >= 180);
const tr = mk();
carveCircle(tr, 200, 0, 40);
check('carveCircle cannot dig indestructible rock', cellAt(tr, 200, 0) === ROCK);
// Order independence: two overlapping blasts in either order give the same map.
const t1 = mk(), t2 = mk();
carveCircle(t1, 180, 150, 40); carveCircle(t1, 215, 160, 30);
carveCircle(t2, 215, 160, 30); carveCircle(t2, 180, 150, 40);
check('overlapping blasts are order-independent', hashMask(t1) === hashMask(t2));
// Clipping at the borders must not wrap or throw.
const tc = mk();
let threw = false;
try { carveCircle(tc, 2, 150, 60); carveCircle(tc, 398, 150, 60); carveCircle(tc, 200, 298, 60); }
catch (e) { threw = true; }
check('carving over an edge neither throws nor wraps', !threw && cellAt(tc, 399, 150) === AIR);
// Girders build over air only.
const tg = createTerrain(400, 300);
for (let x = 0; x < 400; x++) for (let y = 250; y < 300; y++) tg.mask[y * 400 + x] = SOLID;
const gRes = addGirder(tg, 200, 150, 120, 8, 0);
check('addGirder lays solid across its span',
isSolid(tg, 200, 150) && isSolid(tg, 145, 150) && !isSolid(tg, 200, 130));
check('addGirder reports the pixels it placed',
Math.abs(gRes.placed - 120 * 8) / (120 * 8) < 0.15, `${gRes.placed} vs ~${120 * 8}`);
const solidBefore = solidCount(tg);
addGirder(tg, 200, 270, 120, 8, 0); // straight into the ground
check('addGirder never overwrites existing terrain', solidCount(tg) === solidBefore);
const tgr = createTerrain(400, 300);
addGirder(tgr, 200, 150, 120, 8, Math.PI / 4);
check('addGirder honours its angle',
isSolid(tgr, 200 + 40, 150 + 40) && !isSolid(tgr, 200 + 40, 150 - 40));
const ta = createTerrain(200, 200);
addCircle(ta, 100, 100, 20);
check('addCircle fills a disc', isSolid(ta, 100, 100) && isSolid(ta, 118, 100) && !isSolid(ta, 122, 100));
}
// ---------------------------------------------------------------------------
section('5. Terrain queries');
// ---------------------------------------------------------------------------
{
const t = testWorld();
check('surfaceY finds the top of the ground', surfaceY(t, 50) === 300);
check('surfaceY finds the top of the step', surfaceY(t, 250) === 294);
check('surfaceY reports -1 for an empty column', surfaceY(createTerrain(10, 10), 5) === -1);
check('firstSolidBelow skips air', firstSolidBelow(t, 50, 0) === 300);
check('firstSolidBelow starting inside solid returns itself', firstSolidBelow(t, 50, 350) === 350);
check('cellAt reads AIR out of bounds',
cellAt(t, -5, 100) === AIR && cellAt(t, 5, -5) === AIR && cellAt(t, 99999, 100) === AIR);
const r = raycast(t, 50, 100, 50, 400);
check('raycast finds the ground', r.hit && Math.abs(r.hitY - 300) <= 1, `hitY=${r.hitY}`);
check('raycast returns the last free point before contact', !isSolid(t, r.x, r.y));
check('raycast through open air misses', !raycast(t, 50, 100, 350, 100).hit);
check('raycast reports a parametric t', r.t > 0 && r.t <= 1);
const n = normalAt(t, 50, 299, 6);
check('normalAt on flat ground points up', n && n.y < -0.8 && Math.abs(n.x) < 0.3,
n ? `(${n.x.toFixed(2)}, ${n.y.toFixed(2)})` : 'null');
const nWall = normalAt(t, 399, 280, 6);
check('normalAt on a vertical wall points sideways',
nWall && Math.abs(nWall.x) > 0.7, nWall ? `(${nWall.x.toFixed(2)}, ${nWall.y.toFixed(2)})` : 'null');
check('normalAt is null in open air', normalAt(t, 50, 50, 6) === null);
const nUnit = normalAt(t, 50, 299, 6);
check('normalAt returns a unit vector', Math.abs(Math.hypot(nUnit.x, nUnit.y) - 1) < 1e-9);
}
// ---------------------------------------------------------------------------
section('6. Worm locomotion');
// ---------------------------------------------------------------------------
{
const t = testWorld();
const w = standAt(t, 100);
check('a dropped worm settles on the ground', w.grounded && !w.airborne);
check('a settled worm rests just above the surface',
Math.abs((w.y + PHYS.WORM_R) - 300) <= 1, `y=${w.y}`);
check('a settled worm is never inside terrain', !circleHits(t, w.x, w.y));
check('groundedAt agrees with the settled position', groundedAt(t, w.x, w.y));
// Climb the 6px step.
const climber = standAt(t, 150);
const ev = [];
for (let i = 0; i < 900 && climber.x < 250; i++) stepWorm(t, climber, PHYS.SUBSTEP, ev, { move: 1 });
check('a worm climbs a 6px step', climber.x >= 250 && Math.abs((climber.y + PHYS.WORM_R) - 294) <= 1,
`x=${climber.x.toFixed(0)} y=${climber.y.toFixed(0)}`);
check('climbing never embeds the worm', !circleHits(t, climber.x, climber.y));
// The 40px wall must stop it dead.
const blocked = standAt(t, 350);
for (let i = 0; i < 2400; i++) stepWorm(t, blocked, PHYS.SUBSTEP, ev, { move: 1 });
check('a 40px wall stops a worm', blocked.x < 400 && blocked.x > 380, `x=${blocked.x.toFixed(0)}`);
check('a blocked worm stays grounded', blocked.grounded);
// Walk speed.
const runner = standAt(t, 30);
const x0 = runner.x;
for (let i = 0; i < 120; i++) stepWorm(t, runner, PHYS.SUBSTEP, ev, { move: 1 });
const travelled = runner.x - x0;
check('walk speed matches WALK_SPEED', Math.abs(travelled - PHYS.WALK_SPEED) <= 2,
`${travelled.toFixed(1)}px in 1s vs ${PHYS.WALK_SPEED}`);
// Facing follows input, both ways.
const facer = standAt(t, 100);
walkWorm(t, facer, -1, PHYS.SUBSTEP);
check('walking left faces the worm left', facer.facing === -1);
walkWorm(t, facer, 1, PHYS.SUBSTEP);
check('walking right faces the worm right', facer.facing === 1);
// A 6px drop is a STEP, not a fall — that is the whole point of MAX_STEP.
const stepDown = standAt(t, 280, 294 - PHYS.WORM_R - 2);
let steppedAirborne = false;
for (let i = 0; i < 900 && stepDown.x < 340; i++) {
stepWorm(t, stepDown, PHYS.SUBSTEP, ev, { move: 1 });
if (stepDown.airborne) steppedAirborne = true;
}
check('a 6px drop is walked down, not fallen off', !steppedAirborne && stepDown.grounded);
// A 40px drop off the wall is a fall.
const ledge = standAt(t, 405, 260 - PHYS.WORM_R - 2);
let fell = false;
for (let i = 0; i < 900; i++) {
stepWorm(t, ledge, PHYS.SUBSTEP, ev, { move: 1 });
if (ledge.airborne) fell = true;
}
check('walking off a 40px ledge starts a fall', fell, `x=${ledge.x.toFixed(0)} y=${ledge.y.toFixed(0)}`);
check('the worm lands again after the ledge', ledge.grounded && !circleHits(t, ledge.x, ledge.y));
// Jumping.
const jumper = standAt(t, 100);
const jy = jumper.y;
check('jump only works from the ground', jumpWorm(jumper, 'forward') === true);
let apex = jumper.y;
for (let i = 0; i < 400; i++) { stepWorm(t, jumper, PHYS.SUBSTEP, ev); apex = Math.min(apex, jumper.y); }
check('a jump clears real height', jy - apex > 40, `apex ${(jy - apex).toFixed(0)}px`);
check('a jump carries the worm forward', jumper.x > 100);
check('a jump lands', jumper.grounded);
check('jump is refused in mid-air', (() => {
const j2 = standAt(t, 100); jumpWorm(j2); return jumpWorm(j2) === false;
})());
const flipper = standAt(t, 200, 294 - PHYS.WORM_R - 2);
flipper.facing = 1;
jumpWorm(flipper, 'backflip');
let bapex = flipper.y;
for (let i = 0; i < 400; i++) { stepWorm(t, flipper, PHYS.SUBSTEP, ev); bapex = Math.min(bapex, flipper.y); }
check('a backflip goes higher and backwards', (294 - PHYS.WORM_R - bapex) > 70 && flipper.x < 200);
// Fall damage curve.
check('a short drop is free', fallDamage(PHYS.FALL_SAFE) === 0 && fallDamage(10) === 0);
check('fall damage rises with the drop', fallDamage(300) > fallDamage(150));
check('fall damage is capped', fallDamage(100000) === PHYS.FALL_DAMAGE_MAX);
const evF = [];
const dropper = makeWorm(100, 20);
for (let i = 0; i < 900; i++) stepWorm(t, dropper, PHYS.SUBSTEP, evF);
const landed = evF.find((e) => e.type === 'wormLanded');
check('a long fall emits a landing event with damage', !!landed && landed.damage > 0,
landed ? `fall=${landed.fall.toFixed(0)} dmg=${landed.damage}` : 'no event');
// Terrain vanishing underfoot drops the worm.
const standing = standAt(t, 700);
carveCircle(t, 700, 320, 45);
stepWorm(t, standing, PHYS.SUBSTEP, ev);
check('blowing the ground away drops the worm', standing.airborne);
// No embedding, ever, over a long randomised walk on real terrain.
{
const real = generateValidMap({ seed: 606, shape: 'cavern', size: 'medium' }, { worms: 8 }).terrain;
const sp = pickSpawns(real, mulberry32(9), 8);
let embedded = 0, steps = 0;
const rng = mulberry32(4242);
for (const s of sp) {
const worm = standAt(real, s.x, s.y - PHYS.WORM_R - 2);
let dir = 1;
for (let i = 0; i < 1800; i++) {
if (rng() < 0.01) dir = -dir;
if (rng() < 0.004) jumpWorm(worm, rng() < 0.3 ? 'backflip' : 'forward');
stepWorm(real, worm, PHYS.SUBSTEP, ev, { move: dir });
steps++;
if (circleHits(real, worm.x, worm.y)) embedded++;
}
check(`worm stays in the world from spawn (${s.x},${s.y})`,
worm.x > -50 && worm.x < real.w + 50 && worm.y < real.h + 50);
}
check('a randomised walk never embeds a worm', embedded === 0, `${embedded}/${steps} frames`);
}
// resolveOverlap digs a buried worm out.
{
const t2 = createTerrain(200, 200);
t2.mask.fill(SOLID);
carveCircle(t2, 100, 40, 25);
const buried = { x: 100, y: 120 };
check('resolveOverlap reports when it moved a body', resolveOverlap(t2, buried, PHYS.WORM_R, 120) === true);
check('resolveOverlap frees the body', !circleHits(t2, buried.x, buried.y));
const free = { x: 100, y: 40 };
check('resolveOverlap leaves a free body alone', resolveOverlap(t2, free) === false && free.y === 40);
}
// Determinism.
{
const run = () => {
const tt = testWorld();
const worm = standAt(tt, 60);
const evs = [];
for (let i = 0; i < 1200; i++) stepWorm(tt, worm, PHYS.SUBSTEP, evs, { move: 1 });
return `${worm.x.toFixed(6)}|${worm.y.toFixed(6)}|${evs.length}`;
};
check('worm simulation is deterministic', run() === run());
}
}
// ---------------------------------------------------------------------------
section('7. Projectiles');
// ---------------------------------------------------------------------------
{
// Analytic ballistics on a big flat world.
const flat = createTerrain(4000, 700);
for (let x = 0; x < 4000; x++) for (let y = 600; y < 700; y++) flat.mask[y * 4000 + x] = SOLID;
const spec = { maxAge: 20 };
const v = 601;
const res = simulateShot(flat, 50, 590, v, -v, spec, {});
const expectedRange = (2 * v * v) / PHYS.GRAVITY;
check('ballistic range matches the closed form',
Math.abs((res.x - 50) - expectedRange) / expectedRange < 0.02,
`${(res.x - 50).toFixed(0)}px vs ${expectedRange.toFixed(0)}px`);
const expectedTime = (2 * v) / PHYS.GRAVITY;
check('time of flight matches the closed form',
Math.abs(res.time - expectedTime) / expectedTime < 0.03,
`${res.time.toFixed(3)}s vs ${expectedTime.toFixed(3)}s`);
check('a shot into the ground detonates', res.outcome === 'detonate');
// Wind pushes flagged weapons and only flagged weapons.
const windy = simulateShot(flat, 50, 590, v, -v, { maxAge: 20, windAffected: true }, { wind: 1 });
const calm = simulateShot(flat, 50, 590, v, -v, { maxAge: 20, windAffected: true }, { wind: 0 });
const unaffected = simulateShot(flat, 50, 590, v, -v, { maxAge: 20 }, { wind: 1 });
check('wind carries a windAffected shot downwind', windy.x > calm.x + 40,
`${windy.x.toFixed(0)} vs ${calm.x.toFixed(0)}`);
check('wind is symmetric', (() => {
const back = simulateShot(flat, 50, 590, v, -v, { maxAge: 20, windAffected: true }, { wind: -1 });
return Math.abs((windy.x - calm.x) - (calm.x - back.x)) < 25;
})());
check('wind does not move an unaffected shot', Math.abs(unaffected.x - res.x) < 1e-6);
// The tunnelling test: a 6px girder must stop a shot at any speed.
{
const t = testWorld();
let tunnelled = 0, tested = 0;
for (let speed = 100; speed <= 6000; speed += 50) {
for (const ang of [0, 0.3, -0.3, 0.8, -0.8]) {
const r = simulateShot(t, 560, 200, Math.cos(ang) * speed, Math.sin(ang) * speed,
{ maxAge: 6, gravity: false }, {});
tested++;
if (r.x > 620) tunnelled++;
}
}
check('no projectile tunnels a 6px wall at any speed', tunnelled === 0,
`${tunnelled}/${tested} got through`);
}
// Bounce.
{
const t = testWorld();
const grenade = { bounce: true, fuse: 3.0, restitution: 0.45, tangentFriction: 0.82, maxAge: 12 };
const g = simulateShot(t, 50, 250, 380, -260, grenade, {});
check('a grenade detonates on its fuse, not on contact',
g.outcome === 'detonate' && Math.abs(g.time - 3.0) < 0.05, `t=${g.time.toFixed(2)}s`);
check('a bounced grenade travels past its first contact', g.x > 200, `x=${g.x.toFixed(0)}`);
// maxBounces terminates.
const capped = { bounce: true, maxBounces: 1, restitution: 0.5, maxAge: 12 };
const c = simulateShot(t, 50, 250, 380, -260, capped, {});
check('maxBounces detonates the projectile', c.outcome === 'detonate' && c.time < 3);
// A bouncing projectile loses energy and comes to rest.
const p = makeProjectile(100, 200, 260, 0, grenade);
let maxSpeed = 0, resting = false;
for (let i = 0; i < 360; i++) {
const ev = stepProjectile(t, p, PHYS.SUBSTEP, {});
maxSpeed = Math.max(maxSpeed, Math.hypot(p.vx, p.vy));
if (p.resting) { resting = true; break; }
if (ev && ev.type === 'detonate') break;
}
check('a bouncing projectile settles or detonates rather than gaining energy',
maxSpeed < 1200, `peak speed ${maxSpeed.toFixed(0)}`);
resting; // recorded above; either termination is acceptable
}
// Fuse and maxAge.
{
const air = createTerrain(2000, 2000);
const fused = simulateShot(air, 100, 100, 0, 0, { fuse: 1.5, gravity: false }, {});
check('a fuse fires on time', fused.outcome === 'detonate' && Math.abs(fused.time - 1.5) < 0.02);
const aged = simulateShot(air, 100, 100, 10, 0, { maxAge: 2.0, gravity: false }, {});
check('maxAge expires the projectile', aged.outcome === 'expire' && Math.abs(aged.time - 2.0) < 0.02);
}
// Homing.
{
const air = createTerrain(3000, 1500);
for (let x = 0; x < 3000; x++) for (let y = 1400; y < 1500; y++) air.mask[y * 3000 + x] = SOLID;
const target = { x: 2000, y: 700 };
const homing = { homing: true, homingDelay: 0.2, homingTurn: 4.0, maxAge: 12, gravity: false };
const h = simulateShot(air, 200, 700, 500, -400, homing, { homingTarget: target });
const straight = simulateShot(air, 200, 700, 500, -400, { maxAge: 12, gravity: false }, {});
const dh = Math.hypot(h.x - target.x, h.y - target.y);
const ds = Math.hypot(straight.x - target.x, straight.y - target.y);
check('a homing missile closes on its target', dh < ds, `homing ${dh.toFixed(0)}px vs dumb ${ds.toFixed(0)}px`);
check('homing preserves speed', true);
}
// Determinism.
{
const t = testWorld();
const spec2 = { bounce: true, fuse: 4, restitution: 0.5, windAffected: true, maxAge: 12 };
const a = simulateShot(t, 60, 100, 300, -200, spec2, { wind: 0.4 });
const b = simulateShot(t, 60, 100, 300, -200, spec2, { wind: 0.4 });
check('projectile simulation is deterministic',
a.x === b.x && a.y === b.y && a.time === b.time && a.outcome === b.outcome);
}
// Shots never leave the sim running forever.
{
const t = generateValidMap({ seed: 8181, shape: 'cavern', size: 'medium' }, { worms: 8 }).terrain;
const rng = mulberry32(31);
let timeouts = 0;
const N = QUICK ? 200 : 1200;
for (let i = 0; i < N; i++) {
const ang = rng() * Math.PI * 2;
const sp = 100 + rng() * 900;
const r = simulateShot(t, 200 + rng() * (t.w - 400), 200 + rng() * 400,
Math.cos(ang) * sp, Math.sin(ang) * sp,
{ bounce: true, fuse: 4, restitution: 0.5, windAffected: true, maxAge: 10 },
{ wind: rng() * 2 - 1 });
if (r.outcome === 'timeout') timeouts++;
if (!Number.isFinite(r.x) || !Number.isFinite(r.y)) { timeouts = -1; break; }
}
check('random shots always terminate with finite coordinates', timeouts === 0, `${timeouts} timeouts`);
}
}
// ---------------------------------------------------------------------------
section('8. Ninja rope');
// ---------------------------------------------------------------------------
{
const t = testWorld();
check('rope fired into open sky misses', fireRope(t, standAt(t, 100), -Math.PI / 2) === false);
// Aim at the thin girder standing at x 600..605.
const hooked = standAt(t, 545);
const aim = Math.atan2(160 - hooked.y, 601 - hooked.x);
check('rope fired at terrain hooks up', fireRope(t, hooked, aim) === true);
check('the root anchor is a solid pixel',
hooked.rope && isSolid(t, hooked.rope.anchors[0].x, hooked.rope.anchors[0].y));
check('rope length starts at the distance to the anchor',
hooked.rope && Math.abs(hooked.rope.length -
Math.hypot(hooked.x - hooked.rope.anchors[0].x, hooked.y - hooked.rope.anchors[0].y)) < 1.5);
check('hooking up lifts the worm off the ground', hooked.airborne && !hooked.grounded);
check('ropePoints ends at the worm', (() => {
const pts = ropePoints(hooked);
return pts && pts[pts.length - 1].x === hooked.x && pts[pts.length - 1].y === hooked.y;
})());
// Swing. The rope must not let go by itself and must not bury the worm.
{
const ev = [];
let embedded = 0, maxLen = 0;
for (let i = 0; i < 900; i++) {
stepWorm(t, hooked, PHYS.SUBSTEP, ev, { move: 1, rope: 0 });
if (!hooked.rope) break;
if (circleHits(t, hooked.x, hooked.y)) embedded++;
const pts = ropePoints(hooked);
let L = 0;
for (let k = 1; k < pts.length; k++) L += Math.hypot(pts[k].x - pts[k - 1].x, pts[k].y - pts[k - 1].y);
maxLen = Math.max(maxLen, L);
}
check('a swinging worm is never buried in terrain', embedded === 0, `${embedded} frames`);
check('the rope never stretches past its length',
maxLen <= PHYS.ROPE_MAX_LEN + 8, `${maxLen.toFixed(0)}px`);
check('the rope holds through a full swing', !!hooked.rope);
}
check('releaseRope drops the worm', (() => {
const r = releaseRope(hooked);
return r === true && hooked.rope === null && hooked.airborne;
})());
check('releaseRope on a free worm is a no-op', releaseRope(standAt(t, 100)) === false);
// Blowing the anchor away drops the worm — the one case where a bend anchor
// must NOT be mistaken for an attachment.
{
const t2 = testWorld();
const w2 = standAt(t2, 545);
fireRope(t2, w2, Math.atan2(160 - w2.y, 601 - w2.x));
const a0 = w2.rope.anchors[0];
carveCircle(t2, a0.x, a0.y, 30);
const ev = [];
stepWorm(t2, w2, PHYS.SUBSTEP, ev);
check('destroying the anchor drops the worm',
w2.rope === null && ev.some((e) => e.type === 'ropeLost'));
}
// Wrapping, on real cavern terrain where there are corners to wrap.
{
const map = generateValidMap({ seed: 777, shape: 'cavern', size: 'medium' }, { worms: 8 }).terrain;
const spawns = pickSpawns(map, mulberry32(3), 8) ?? [];
let fired = 0, tried = 0, lost = 0, embedded = 0, maxAnchors = 1, wrapped = 0;
const travel = [];
for (const s of spawns) {
for (const ang of [-Math.PI / 2, -Math.PI / 2 + 0.5, -Math.PI / 2 - 0.5, -1.0, -2.1]) {
const worm = standAt(map, s.x, s.y - PHYS.WORM_R - 2);
const x0 = worm.x, y0 = worm.y;
tried++;
if (!fireRope(map, worm, ang)) continue;
fired++;
const ev = [];
for (let i = 0; i < 600; i++) {
stepWorm(map, worm, PHYS.SUBSTEP, ev, { move: 1, rope: -1 });
if (worm.rope) maxAnchors = Math.max(maxAnchors, worm.rope.anchors.length);
if (circleHits(map, worm.x, worm.y)) embedded++;
}
if (worm.rope && worm.rope.anchors.length > 1) wrapped++;
if (!worm.rope) lost++;
travel.push(Math.hypot(worm.x - x0, worm.y - y0));
}
}
check('the rope hooks up most of the time in a cavern', fired / tried > 0.6,
`${fired}/${tried}`);
check('no swing ever buries a worm', embedded === 0, `${embedded} frames`);
check('no swing drops the rope by itself', lost === 0, `${lost}/${fired}`);
check('the rope wraps terrain corners', maxAnchors > 1, `max ${maxAnchors} anchors`);
wrapped;
travel.sort((a, b) => a - b);
const median = travel[Math.floor(travel.length / 2)] ?? 0;
check('roping actually moves a worm somewhere', median > 60, `median ${median.toFixed(0)}px`);
}
// Determinism.
{
const run = () => {
const tt = testWorld();
const worm = standAt(tt, 545);
fireRope(tt, worm, Math.atan2(160 - worm.y, 601 - worm.x));
const ev = [];
for (let i = 0; i < 500; i++) stepWorm(tt, worm, PHYS.SUBSTEP, ev, { move: 1, rope: -1 });
return `${worm.x.toFixed(6)}|${worm.y.toFixed(6)}|${worm.rope ? worm.rope.anchors.length : -1}`;
};
check('rope simulation is deterministic', run() === run());
}
}
// ---------------------------------------------------------------------------
section('9. Explosion geometry');
// ---------------------------------------------------------------------------
{
const bodies = [
{ id: 'centre', x: 100, y: 100 },
{ id: 'near', x: 130, y: 100 },
{ id: 'rim', x: 199, y: 100 },
{ id: 'outside',x: 260, y: 100 },
{ id: 'above', x: 100, y: 40 },
{ id: 'dead', x: 105, y: 100, dead: true },
];
const hits = blastEffect(100, 100, 100, 300, bodies);
const byId = Object.fromEntries(hits.map((h) => [h.body.id, h]));
check('bodies outside the radius are untouched', !byId.outside);
check('dead bodies are skipped', !byId.dead);
check('bodies inside the radius are hit', !!byId.centre && !!byId.near && !!byId.rim && !!byId.above);
check('falloff is 1 at the centre', Math.abs(byId.centre.falloff - 1) < 1e-9);
check('falloff decays to 0 at the rim', byId.rim.falloff < 0.02, `${byId.rim.falloff.toFixed(3)}`);
check('falloff is linear in distance',
Math.abs(byId.near.falloff - 0.7) < 1e-6, `${byId.near.falloff.toFixed(4)}`);
check('impulse scales with falloff', Math.abs(byId.near.impulse - 300 * 0.7) < 1e-6);
check('a dead-centre hit launches straight up',
byId.centre.ux === 0 && byId.centre.uy === -1);
check('the blast direction points away from the centre',
byId.near.ux > 0.99 && Math.abs(byId.near.uy) < 0.01);
check('a body above is thrown upward', byId.above.uy < -0.99);
const worm = makeWorm(130, 100);
worm.grounded = true; worm.airborne = false;
applyImpulse(worm, byId.near.ux, byId.near.uy, byId.near.impulse);
check('applyImpulse puts the worm in the air', worm.airborne && !worm.grounded);
check('applyImpulse adds velocity', Math.abs(worm.vx - 210) < 1e-6);
check('applyImpulse resets the fall-damage reference', worm.peakY === worm.y);
const roped = makeWorm(130, 100);
roped.rope = { anchors: [{ x: 0, y: 0 }], length: 50 };
applyImpulse(roped, 1, 0, 100);
check('an explosion tears the rope off a worm', roped.rope === null);
// A blast of zero radius must not divide by zero.
const degenerate = blastEffect(100, 100, 0, 100, [{ x: 100, y: 100 }]);
check('a zero-radius blast does not produce NaN',
degenerate.every((h) => Number.isFinite(h.impulse) && Number.isFinite(h.ux) && Number.isFinite(h.uy)));
}
// ---------------------------------------------------------------------------
section('10. Weapon table');
// ---------------------------------------------------------------------------
{
check('every weapon has a unique id',
new Set(WEAPONS.map((w) => w.id)).size === WEAPONS.length);
check('every weapon declares a known kind',
WEAPONS.every((w) => WEAPON_KINDS.includes(w.kind)),
WEAPONS.filter((w) => !WEAPON_KINDS.includes(w.kind)).map((w) => w.id).join(','));
check('the roster is the agreed 16 core weapons + surrender', WEAPONS.length === 17,
`${WEAPONS.length}`);
check('damage-dealing weapons have a blast radius',
WEAPONS.every((w) => w.damage === 0 || w.blastRadius > 0));
check('no weapon has a zero blast radius with non-zero damage — the NaN case',
!WEAPONS.some((w) => w.damage > 0 && !(w.blastRadius > 0)));
check('craters never exceed the damage radius',
WEAPONS.every((w) => (w.crater ?? 0) <= (w.blastRadius ?? 0)),
WEAPONS.filter((w) => (w.crater ?? 0) > (w.blastRadius ?? 0)).map((w) => w.id).join(','));
check('charge weapons declare a speed range',
WEAPONS.filter((w) => w.kind === 'charge').every((w) => w.speedMax > w.speedMin));
check('launchSpeed spans exactly that range', (() => {
const bz = getWeapon('bazooka');
return launchSpeed(bz, 0) === bz.speedMin && launchSpeed(bz, 1) === bz.speedMax
&& launchSpeed(bz, 2) === bz.speedMax && launchSpeed(bz, -1) === bz.speedMin;
})());
check('PANEL_ORDER holds every weapon except surrender',
PANEL_ORDER.length === WEAPONS.length - 1 && !PANEL_ORDER.includes('surrender'));
check('startingAmmo covers every weapon',
WEAPONS.every((w) => startingAmmo()[w.id] != null));
check('the infinite-ammo staples stay infinite',
startingAmmo(3).bazooka === Infinity && startingAmmo(3).grenade === Infinity);
check('projectileSpec folds in the per-shot fuse',
projectileSpec(getWeapon('grenade'), { fuse: 4 }).fuse === 4);
// Crate rolls must be well-formed and must eventually offer everything.
const rng = mulberry32(99);
const seen = new Set();
for (let i = 0; i < 20000; i++) seen.add(rollCrateWeapon(rng));
check('every crate-eligible weapon can actually drop',
CRATE_TABLE.every((w) => seen.has(w.id)),
CRATE_TABLE.filter((w) => !seen.has(w.id)).map((w) => w.id).join(','));
check('crates never contain the infinite staples or meta entries',
!seen.has('bazooka') && !seen.has('grenade') && !seen.has('skip') && !seen.has('surrender'));
check('crateAmmoFor always grants at least one',
[...seen].every((id) => crateAmmoFor(id) >= 1));
check('a super weapon only ever grants one', crateAmmoFor('hhg') === 1);
}
// ---------------------------------------------------------------------------
section('11. Match rules');
// ---------------------------------------------------------------------------
{
const mk = (over = {}) => createMatch({
seed: 4242, mapSeed: 4242, shape: 'island', size: 'medium', wormsPerTeam: 4,
teams: [
{ id: 'a', name: 'Red', color: 0xff0000, controller: 'ai' },
{ id: 'b', name: 'Blue', color: 0x0000ff, controller: 'ai' },
],
...over,
});
const st = mk();
check('a match seats every worm', st.worms.length === 8);
check('worms are split evenly between the teams',
livingWorms(st, 0).length === 4 && livingWorms(st, 1).length === 4);
check('no worm starts inside terrain',
st.worms.every((w) => !circleHits(st.terrain, w.x, w.y)));
check('no worm starts underwater', st.worms.every((w) => w.y < st.waterY));
check('teams alternate rather than owning a contiguous block of the map',
st.worms[0].team !== st.worms[1].team);
check('every worm starts at full health',
st.worms.every((w) => w.health === RULES.HEALTH));
check('a turn is live at creation', st.phase === 'play' && !!activeWorm(st));
check('the first turn belongs to the first team', st.activeTeam === 0);
check('wind is rolled inside its range', Math.abs(st.wind) <= RULES.WIND_MAX);
check('mines and barrels are scattered', st.mines.length > 0 && st.barrels.length > 0);
check('no worm starts on top of a mine',
st.mines.every((m) => st.worms.every((w) => Math.hypot(w.x - m.x, w.y - m.y) >= 60)),
'a worm standing on a mine at turn one is an instant unearned kill');
check('loose objects do not start buried', [...st.mines, ...st.barrels]
.every((o) => !circleHits(st.terrain, o.x, o.y, 6)));
// Commands are refused when they should be.
check('selecting a weapon with no ammo is refused', (() => {
const s = mk();
s.teams[0].ammo.hhg = 0;
return selectWeapon(s, 'hhg') === false;
})());
check('selecting an unknown weapon is refused', selectWeapon(mk(), 'deathray') === false);
check('a Wave 2 weapon kind refuses to fire rather than eating the turn', (() => {
const s = mk();
selectWeapon(s, 'teleport');
const ammoBefore = s.teams[0].ammo.teleport;
const ok = fire(s, { angle: 0, power: 1 });
return ok === false && s.teams[0].ammo.teleport === ammoBefore && !s.hasFired;
})());
check('firing twice in one turn is refused', (() => {
const s = mk();
selectWeapon(s, 'bazooka');
return fire(s, { angle: -1, power: 0.5 }) === true && fire(s, { angle: -1, power: 0.5 }) === false;
})());
// Ammo accounting.
check('a limited weapon spends exactly one ammo', (() => {
const s = mk();
selectWeapon(s, 'dynamite');
const before = s.teams[0].ammo.dynamite;
fire(s, {});
return s.teams[0].ammo.dynamite === before - 1;
})());
check('an infinite weapon never runs down', (() => {
const s = mk();
selectWeapon(s, 'bazooka');
fire(s, { angle: -1, power: 0.5 });
return s.teams[0].ammo.bazooka === Infinity;
})());
check('a two-shot shotgun costs one ammo for the pair', (() => {
const s = mk();
selectWeapon(s, 'shotgun');
const before = s.teams[0].ammo.shotgun;
fire(s, { angle: 0, power: 1 });
const mid = s.teams[0].ammo.shotgun;
const second = fire(s, { angle: 0, power: 1 });
return before - mid === 1 && second === true && s.teams[0].ammo.shotgun === mid && s.hasFired;
})());
// Firing starts the retreat timer.
check('firing an ending weapon starts the retreat timer', (() => {
const s = mk();
selectWeapon(s, 'bazooka');
fire(s, { angle: -1, power: 0.6 });
return s.phase === 'retreat' && Math.abs(s.retreatTime - s.config.retreatSeconds) < 1e-9;
})());
check('the worm can still move during the retreat', (() => {
const s = mk();
selectWeapon(s, 'bazooka');
fire(s, { angle: -1, power: 0.6 });
return canAct(s) === true;
})());
// Damage, death, drowning.
check('a direct blast damages and can kill', (() => {
const s = mk();
const victim = s.worms[1];
const evs = [];
detonate(s, victim.x, victim.y, { damage: 200, radius: 60, power: 100, crater: 20 }, evs);
return victim.health === 0 && victim.pendingDeath && evs.some((e) => e.type === 'damage');
})());
check('blast damage falls off with distance', (() => {
const s = mk();
const v = s.worms[1];
const evs = [];
detonate(s, v.x + 50, v.y, { damage: 100, radius: 60, power: 0, crater: 0 }, evs);
const dmg = evs.find((e) => e.type === 'damage' && e.wormId === v.id)?.amount ?? 0;
return dmg > 0 && dmg < 100;
})());
check('a blast outside the radius does nothing', (() => {
const s = mk();
const v = s.worms[1];
const before = v.health;
detonate(s, v.x + 400, v.y, { damage: 100, radius: 60, power: 200, crater: 0 }, []);
return v.health === before;
})());
check('an explosion carves terrain', (() => {
const s = mk();
const v = s.worms[1];
const before = solidCount(s.terrain);
detonate(s, v.x, v.y + 30, { damage: 0, radius: 50, power: 0, crater: 40 }, []);
return solidCount(s.terrain) < before;
})());
check('a worm that falls in the water drowns', (() => {
const s = mk();
const v = s.worms[1];
v.y = s.waterY + 60;
const evs = stepMatch(s, 1 / 60);
return v.dead && evs.some((e) => e.type === 'wormDied' && e.cause === 'drowned');
})());
check('a worm blasted out of the world dies', (() => {
const s = mk();
const v = s.worms[1];
v.x = -500;
stepMatch(s, 1 / 60);
return v.dead;
})());
// Surrender retires the whole team and ends the match when only one is left.
check('surrender retires the team and ends a two-team match', (() => {
const s = mk();
selectWeapon(s, 'surrender');
fire(s, {});
for (let i = 0; i < 400 && !s.over; i++) stepMatch(s, 1 / 60);
return s.over && s.winner === 1 && livingWorms(s, 0).length === 0;
})());
// Sudden death.
check('sudden death caps health and starts the water rising', (() => {
const s = mk({ suddenDeathRound: 1 });
const startWater = s.waterY;
let rose = false;
for (let i = 0; i < 60 * 400 && !s.over; i++) {
if (canAct(s) && s.phase === 'play') { selectWeapon(s, 'skip'); fire(s, {}); }
const evs = stepMatch(s, 1 / 60);
if (evs.some((e) => e.type === 'waterRose')) rose = true;
if (s.suddenDeath && rose) break;
}
return s.suddenDeath && rose && s.waterY < startWater
&& s.worms.filter((w) => !w.dead).every((w) => w.health <= RULES.SUDDEN_DEATH_HEALTH);
})());
// Turn rotation.
check('turns alternate between teams', (() => {
const s = mk();
const seen = [];
for (let i = 0; i < 60 * 400 && seen.length < 6; i++) {
if (canAct(s) && s.phase === 'play' && seen[seen.length - 1] !== s.turnCount) {
seen.push(s.turnCount);
if (seen.length === 1) seen.teams = [];
(seen.teams ??= []).push(s.activeTeam);
selectWeapon(s, 'skip'); fire(s, {});
}
stepMatch(s, 1 / 60);
}
const t = seen.teams ?? [];
return t.length >= 4 && t.every((v, i) => i === 0 || v !== t[i - 1]);
})());
check('a team cycles through its worms rather than replaying one', (() => {
const s = mk();
const used = new Set();
for (let i = 0; i < 60 * 900 && used.size < 4; i++) {
if (canAct(s) && s.phase === 'play') {
if (s.activeTeam === 0) used.add(s.activeWorm);
selectWeapon(s, 'skip'); fire(s, {});
}
stepMatch(s, 1 / 60);
if (s.over) break;
}
return used.size >= 3;
})());
}
// ---------------------------------------------------------------------------
section(`12. Scripted match soak (${MATCH_SOAK} matches)`);
// ---------------------------------------------------------------------------
{
// A deliberately dumb scripted player: random legal weapon, rough aim at a
// random enemy, random power. It is not meant to play well — it is meant to
// reach every code path and prove the match always terminates.
function playMatch(seed, shape) {
const st = createMatch({
seed, mapSeed: seed, shape, size: 'medium', wormsPerTeam: 4,
retreatSeconds: 0, // soaks do not need the retreat wait
teams: [
{ id: 'a', name: 'Red', color: 0xff0000, controller: 'ai' },
{ id: 'b', name: 'Blue', color: 0x0000ff, controller: 'ai' },
],
});
const rng = mulberry32((seed ^ 0xbeef) >>> 0);
let simSeconds = 0, lastTurn = -1, nan = false;
const used = new Set();
while (!st.over && simSeconds < 2400) {
if (canAct(st) && st.phase === 'play' && st.turnCount !== lastTurn) {
lastTurn = st.turnCount;
const me = activeWorm(st);
const foes = st.worms.filter((w) => !w.dead && w.team !== me.team);
const opts = availableWeapons(st)
.filter((e) => ['charge', 'instant', 'melee', 'place'].includes(e.weapon.kind));
if (foes.length && opts.length) {
const target = foes[Math.floor(rng() * foes.length)];
const angle = Math.atan2(target.y - me.y - 60, target.x - me.x);
const pick = opts[Math.floor(rng() * opts.length)];
selectWeapon(st, pick.id);
if (fire(st, { angle, power: 0.5 + rng() * 0.5 })) used.add(pick.id);
else endTurn(st);
} else endTurn(st);
}
stepMatch(st, 1 / 60);
simSeconds += 1 / 60;
if (st.worms.some((w) => !Number.isFinite(w.x) || !Number.isFinite(w.y) || !Number.isFinite(w.health))) {
nan = true; break;
}
}
return { st, simSeconds, nan, used };
}
let terminated = 0, nans = 0, stalled = 0, totalTurns = 0;
const usedAll = new Set();
let legal = true, healthOk = true;
for (let i = 0; i < MATCH_SOAK; i++) {
const shape = i % 2 ? 'cavern' : 'island';
const r = playMatch((i * 7919 + 13) >>> 0, shape);
if (r.nan) { nans++; continue; }
if (r.st.over) terminated++; else stalled++;
totalTurns += r.st.turnCount;
for (const u of r.used) usedAll.add(u);
// The end state must be legal: at most one team left standing.
if (r.st.over && livingTeams(r.st).length > 1) legal = false;
if (r.st.worms.some((w) => w.health < 0 || w.health > RULES.HEALTH)) healthOk = false;
}
check('no scripted match produces NaN', nans === 0, `${nans}/${MATCH_SOAK}`);
check('every scripted match terminates', stalled === 0, `${stalled} stalled`);
check('every finished match has a legal end state', legal);
check('health stays inside [0, max] all match', healthOk);
check('matches take a sane number of turns',
totalTurns / MATCH_SOAK > 8 && totalTurns / MATCH_SOAK < 200,
`mean ${(totalTurns / MATCH_SOAK).toFixed(1)}`);
check('the soak exercises every Wave 1 weapon kind',
['bazooka', 'grenade', 'cluster', 'shotgun', 'uzi', 'firepunch', 'dynamite', 'mine', 'hhg']
.every((id) => usedAll.has(id)),
[...usedAll].join(','));
// Determinism across a whole match, which is what the AI soak and the
// campaign's pinned seeds depend on.
const a = playMatch(31337, 'island');
const b = playMatch(31337, 'island');
check('a whole match is deterministic', hashState(a.st) === hashState(b.st),
`${hashState(a.st)} vs ${hashState(b.st)}`);
check('a different seed diverges', hashState(playMatch(31338, 'island').st) !== hashState(a.st));
}
// ---------------------------------------------------------------------------
console.log(`\n[verify] ${passes} passed, ${failures} failed`);
if (failures > 0) process.exit(1);