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