1072 lines
48 KiB
JavaScript
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);
|