Files
splatter-tennis/test.js
T
wuwiandClaude Opus 5 6a16e77480 Triage: always kill the body that would reach you first
The sequence drill tested nothing — the strip was always visible (no memory),
the batch was slow (no urgency), the aim was easy (no execution). A checklist
with gore, and Oliver said so: "not interesting enough to keep me playing".

Settled in discussion, he picked triage + streak. One rule: always kill the
body that would reach you first. Nothing is called, nothing shown — the court
is the puzzle. arrival = distance / speed down a held lane, and the roster's
speed spread means the answer is not the closest body: a runner at the far
baseline beats a roller at the service line. Court sense — and it trains the
exact target-priority read the gore mode rewards.

Wounds are free: only the kill is judged, so pre-softening a heavy while it
is third in line is legal and eventually necessary. The slack (0.9s) makes
near-ties legal both ways, or the rule is a coin flip. The streak is both
punishment and payout: x1..x5, a misread shows OUT OF TURN and resets it, and
nothing else happens — the queue keeps walking. Caught is the real setback.
Best score banks to localStorage; the world board can carry it if it ships.

drillMark draws a chevron over the queue head for playtest calibration. It
answers the exact question the mode asks, so it must be off in anything
shipped.

Deleted: the alphabet, the batch, the strip, the eaters. Shamblers are
first-class stream members with pts 20; a roller is 100.

30 tests, rewritten around arithmetic riggings (known speeds and distances)
instead of luck; 20 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:32:17 +02:00

480 lines
23 KiB
JavaScript

// Headless state-machine tests: `node test.js`.
// The game's modules are imported for real; only the platform (DOM, WebAudio, storage) is stubbed.
// These pin the court-ownership contract: horde, sudden-death duel, boss and changeover are
// EXCLUSIVE control modes — no interleaving of beats may ever let two of them run at once.
// Born from three field freezes: the last one was a duel starting inside a live horde
// (pretoss = no steering, horde = no serving, spawns keep coming → unrecoverable).
import assert from 'node:assert/strict';
// ---- DETERMINISM. This suite is a Dockerfile build stage: a red run blocks the deploy, so a test
// that fails one run in five is worse than no gate at all — it trains you to re-run it. Two of the
// drill tests did exactly that (they asserted on a random batch composition and on a snapshot of a
// population that legitimately empties), and the pipeline caught what 60 local runs had not.
//
// The game leans on Math.random() everywhere — jitter, variants, lanes, spawn gaps — so the fix is
// to make the STREAM reproducible rather than to strip randomness out of the game. Fixed seed by
// default, and `SEED=1234 node test.js` re-rolls it: run the suite over a spread of seeds when you
// touch anything probabilistic, and any failure you see is one you can reproduce exactly.
//
// The cost to know about: under one seed a guarded test can silently take its early-out every time
// and quietly stop testing anything. If you add a `return` for an awkward deal, fuzz a few seeds and
// make sure the body actually runs.
let _seed = (Number(process.env.SEED) || 20260811) >>> 0;
Math.random = () => { // xorshift32 — small, fast, good enough for jitter
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 4294967296;
};
// ---- platform stubs, installed before the game modules load ----------------
const absorb = new Proxy(function () {}, {
get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb),
set: () => true,
apply: () => absorb,
construct: () => absorb,
});
const fakeCtx = () => new Proxy({}, {
get: (t, p) => {
if (p === 'canvas') return fakeCanvas();
if (p === 'measureText') return () => ({ width: 0 });
if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h });
if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} });
return () => undefined;
},
set: () => true,
});
function fakeCanvas() {
return {
width: 270, height: 480, style: {},
getContext: () => fakeCtx(),
addEventListener() {}, removeEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }),
toDataURL: () => '',
};
}
const store = new Map();
globalThis.localStorage = {
getItem: k => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
};
globalThis.document = {
getElementById: () => fakeCanvas(),
createElement: () => fakeCanvas(),
addEventListener() {}, removeEventListener() {},
body: { style: {}, appendChild() {} },
};
globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional)
globalThis.AudioContext = function () { return absorb; };
globalThis.webkitAudioContext = globalThis.AudioContext;
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
globalThis.requestAnimationFrame = () => 0;
globalThis.devicePixelRatio = 2;
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js');
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const { TUNE, bossArt, COURT } = await import('./src/config.js');
const DT = 1 / 60;
function freshSplatter() {
restartRun();
G.mode = 'splatter';
G.everTransitioned = true;
setupPoint();
}
let passed = 0;
function test(name, fn) {
freshSplatter();
fn();
passed++;
console.log(' ok - ' + name);
}
// ---- the contract -----------------------------------------------------------
test('sanity: a horde starts and owns the court in rally phase', () => {
forceZombies();
assert.ok(G.horde, 'horde should start on a clean court');
assert.equal(G.phase, 'rally', 'horde steering requires rally phase');
});
test('a duel refuses to start while a horde is live', () => {
forceZombies();
startDuel();
assert.equal(G.interlude, false, 'startDuel must refuse during a horde');
});
test('a horde refuses to start while a duel is live', () => {
startDuel();
assert.ok(G.interlude, 'duel should start on a clean court');
forceZombies();
assert.equal(G.horde, null, 'startHorde must refuse during a duel');
});
test('field repro: a clear latched before the horde must not advance the beat mid-horde', () => {
// A game won during the set card left clearing+afterClear armed while pendingHorde cashed:
spawnZombie(0, 5, 'a');
G.clearing = true; G.afterClear = 'game'; G.duelDue = true;
forceZombies();
assert.ok(G.horde, 'setup: horde live over a latched clear');
zombies.length = 0; // the gun mows the court down to an empty frame between spawns
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; }
assert.equal(G.interlude, false, 'the scheduled duel must NOT fire inside the horde');
assert.ok(G.horde, 'the horde must keep running');
assert.ok(G.clearing, 'the clear waits for the horde to end');
// ...and once the horde expires, the parked clear advances and the duel fires — sequentially.
for (let i = 0; i < (TUNE.hordeLen + 3) * 10 && G.horde; i++) { update(0.1); zombies.length = 0; }
assert.equal(G.horde, null, 'horde over');
for (let i = 0; i < 30 && !G.interlude; i++) { update(DT); if (!G.interlude) zombies.length = 0; }
assert.ok(G.interlude, 'the parked duel fires at the first clear AFTER the horde');
});
test('kills mid-duel do not score: resolveInterlude is the only exit', () => {
startDuel();
const before = JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]);
for (let i = 0; i < 20; i++) splatPoint('P');
splatPoint('O');
assert.equal(JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]), before,
'splatPoint must be a no-op during the duel');
resolveInterlude('P');
assert.equal(G.interlude, false, 'the duel still resolves normally');
});
test('invariant net: an injected duel-inside-horde is repaired within one frame', () => {
forceZombies();
G.interlude = { introT: 0 }; // simulate an unknown fourth path corrupting the state
G.phase = 'pretoss'; // the welded-player symptom: no steering, no serving
update(DT);
assert.equal(G.interlude, false, 'watchdog must drop the impossible duel');
assert.equal(G.duelDue, true, 'and re-schedule it instead of eating it');
assert.equal(G.phase, 'rally', 'and hand the finger back to the player');
assert.ok(G.missLog.some(e => e.result === 'INVARIANT'), 'the repair leaves evidence in the miss log');
});
test('a pending horde defers while a duel runs, then fires after it', () => {
startDuel();
G.interlude.introT = 0;
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold while the duel owns the court');
assert.ok(G.pendingHorde, 'and stay armed');
resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // dismiss the win popups; the over beat then expires
for (let i = 0; i < 300 && !G.horde; i++) update(DT); // the post-duel over beat runs 2.2 s
assert.ok(G.horde, 'the set still gets its horde, after the duel');
});
// ---- regressions mined from the project log (each of these shipped broken once) ----------------
test('a pending horde never pours onto a boss stage', () => {
// commit 7803c83: at game 30 (set AND boss chapter) the set's horde latch used to cash into the
// boss fight. The latch must hold while the boss owns the court, and fire after.
startBossFight(3);
assert.ok(G.bossFight, 'setup: boss on court');
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold during the boss fight');
assert.ok(G.pendingHorde, 'and stay armed');
});
test('the boss win card is EPIC and survives the set handback (the game-30 clobber)', () => {
// endBossFight once ran the parked 'set' handback LAST, so the routine SET card overwrote the
// EPIC card before it drew a frame. The handback runs first now and the boss card wins.
startBossFight(3);
G.afterDuel = 'set'; G.seen.horde = true;
zombies.length = 0;
endBossFight();
assert.equal(G.bossFight, 0, 'fight over');
assert.equal(G.card && G.card.title, 'EPIC!', 'the boss card must not be clobbered by the set card');
assert.ok(G.pendingHorde, 'the set the boss interrupted still owes its horde');
for (let i = 0; i < 200 && !G.horde; i++) { update(0.1); zombies.length = 0; }
assert.ok(G.horde, 'and the horde arrives after the boss beat, not during it');
});
test('mgT counts down in every phase and releases the serve lock', () => {
// commit e75b388: the burst clock used to tick only during rally, so a beat that moved the phase
// mid-burst stranded mgT above zero forever — and mgT > 0 blocks serving. Permanent lock.
G.mgT = 0.5; G.gunId = 'gun';
assert.equal(G.phase, 'pretoss', 'setup: not in rally');
for (let i = 0; i < 60; i++) { update(DT); zombies.length = 0; }
assert.ok(G.mgT <= 0, 'the burst clock must run out outside rally');
assert.equal(G.phase, 'pretoss', 'and hand back the serve loop');
});
test('a won duel pays out: the tin and the trainer', () => {
// commit c75bd5b ("pay out every won duel") + the heal that is THE reason to want a duel.
G.hp = G.hpMax - 3;
startDuel();
G.interlude.introT = 0;
const rackBefore = G.rack.length + (G.temp ? 1 : 0);
resolveInterlude('P');
const rackAfter = G.rack.length + (G.temp ? 1 : 0);
assert.ok(rackAfter > rackBefore, 'winning the duel must hand over a weapon');
assert.equal(G.hp, G.hpMax - 3 + TUNE.healPerDuel, 'and the trainer heals healPerDuel');
});
test('a lost duel banks their game — and their set — without a horde', () => {
score.games[1] = 5;
startDuel();
G.interlude.introT = 0;
resolveInterlude('O');
assert.equal(G.sets[1], 1, 'their 6th game banks their set');
assert.deepEqual(score.games, [0, 0], 'games reset');
assert.ok(zombies.filter(z => z.variant !== 'r').length >= 5, 'the dead lose patience: an instant surge');
assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde');
});
// ---- the announcements: nothing that changes the control mode may arrive unannounced -----------
test('a set warns THEY KEEP COMING before the wave lands', () => {
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement');
assert.ok(G.hordeWarn > 0, 'the breather is running');
assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so');
for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second
assert.equal(G.horde, null, 'the breather actually holds');
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.ok(G.horde, 'and the wave lands when it runs out');
});
test('a mode that steals the court during the warning re-arms the horde', () => {
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.ok(G.hordeWarn > 0, 'setup: warning running');
startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here
G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping)
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.equal(G.horde, null, 'the wave must not land inside the duel');
assert.ok(G.pendingHorde, 'the set is still owed its horde');
});
test('a chapter boss announces itself and hands the court back on its own', () => {
startBossFight(2);
assert.ok(G.modal, 'the walk-on is an overlay, not a band card');
assert.equal(G.modal.title, bossArt(2).name, 'it names the boss');
assert.deepEqual(G.modal.body, ['ENTERED THE COURT']);
for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT);
assert.ok(G.modal, 'and holds the frozen court while the player reads it');
for (let i = 0; i < 20 && G.modal; i++) update(DT);
assert.equal(G.modal, null, 'then lets go without waiting for a tap');
assert.ok(G.bossFight, 'the fight is on');
});
test('a burst fires through the beat that follows the kill that emptied the court', () => {
// The gun/flamer/beacon used to require phase 'rally', and every beat raised by clearing the
// court (a point, a won game, the mop-up) parks the phase in 'over' — so the weapon fell silent
// exactly when it had killed everything. Its OWN clock is the only thing that ends it.
G.mgT = 2; G.gunId = 'flamer'; G.mgCool = 0;
G.phase = 'over'; G.overT = 1.5; // the won-game beat, running behind the burst
pellets.length = 0;
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } // an empty court, as after the last kill
assert.equal(G.phase, 'over', 'setup: the beat is still running');
assert.ok(pellets.length > 0, 'the burst keeps firing through it');
assert.ok(G.mgT > 0, 'and its window is still open');
});
// ---- the triage drill: one rule, and every part of it pinned --------------------------------
// The rule: always kill the body that would reach you first (arrival = distance / speed down a held
// lane). Wounds are free, the slack forgives near-ties, the streak is the punishment and the payout.
function drillPlaying(seconds = 1) {
startDrill();
for (let i = 0; i < seconds * 60; i++) update(DT);
}
// kill a specific body through the shared funnel, via the ball's contact test
const drillKillBody = zb => {
for (let i = 0; i < 60 && zombies.includes(zb); i++) {
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0;
update(DT);
}
assert.ok(!zombies.includes(zb), 'the body should be down');
};
// a hand-built queue with KNOWN arrivals, so a test judges against arithmetic rather than luck:
// far-but-fast arrives at t=4, near-but-slow at t=8, dawdler at t=16
function riggedQueue() {
startDrill();
zombies.length = 0;
G.drill.spawnT = 999; // hold the stream while the fixture stands
const rig = [];
for (const [z, v] of [[COURT.L - 8, 2.0], [COURT.L - 4, 0.5], [COURT.L - 16, 1.0]]) {
spawnZombie(0, z, 'a');
const zb = zombies[zombies.length - 1];
zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1;
rig.push(zb);
}
return rig; // [first, second, third] by arrival: 4 s, 8 s, 16 s
}
test('a drill owns the court: every other mode refuses while it runs', () => {
assert.ok(startDrill(), 'a drill starts on a clean court');
startDuel();
assert.equal(G.interlude, false, 'startDuel must refuse during a drill');
forceZombies();
assert.equal(G.horde, null, 'startHorde must refuse during a drill');
startBossFight(1);
assert.equal(G.bossFight, 0, 'startBossFight must refuse during a drill');
assert.equal(startDrill(), false, 'and a drill must refuse a second drill');
});
test('a drill refuses to start while another mode owns the court', () => {
startDuel();
assert.ok(G.interlude, 'setup: the duel has the court');
assert.equal(startDrill(), false, 'startDrill must refuse during a duel');
assert.equal(G.drill, null, 'and leave no half-built state behind');
});
test('the queue ranks by ARRIVAL, not distance: far-but-fast outranks near-but-slow', () => {
const [fast, slow] = riggedQueue();
const q = drillQueue();
assert.equal(q[0], fast, 'the 2.0 m/s body 8 m out arrives first (4 s vs 8 s)');
assert.equal(q[1], slow, 'the 0.5 m/s body 4 m out is second');
assert.ok(arrival(q[0]) < arrival(q[1]) && arrival(q[1]) < arrival(q[2]), 'arrivals are ordered');
});
test('killing the queue head grows the streak and banks points', () => {
const [fast] = riggedQueue();
const s0 = G.drill.score;
drillKillBody(fast);
assert.equal(G.drill.streak, 1, 'a correct kill grows the streak');
assert.equal(G.drill.faults, 0, 'and is no fault');
assert.ok(G.drill.score > s0, 'and banks points');
});
test('killing out of turn is a fault, resets the streak, and sweeps nothing', () => {
const rig = riggedQueue();
G.drill.streak = 7; G.drill.score = 500;
drillKillBody(rig[2]); // the 16 s dawdler, with a 4 s body still walking
assert.equal(G.drill.faults, 1, 'a misread faults');
assert.equal(G.drill.streak, 0, 'and the streak dies');
assert.equal(G.drill.score, 500, 'the banked score is untouched');
assert.ok(zombies.includes(rig[0]) && zombies.includes(rig[1]), 'the court is NOT swept — the queue keeps walking');
});
test('the slack forgives a near-tie', () => {
startDrill();
zombies.length = 0; G.drill.spawnT = 999;
for (const dz of [6, 6.3]) { // two bodies arriving ~0.3 s apart at 1 m/s
spawnZombie(dz > 6 ? 2 : -2, COURT.L - dz, 'a');
const zb = zombies[zombies.length - 1];
zb.drill = true; zb.pts = 20; zb.v = 1.0; zb.hp = zb.hpMax = 1;
}
const second = zombies[zombies.length - 1];
drillKillBody(second); // kill #2 of a 0.3 s tie: inside drillSlack 0.9
assert.equal(G.drill.faults, 0, 'inside the slack, either of a near-tie is legal');
assert.equal(G.drill.streak, 1, 'and it counts');
});
test('wounds are free: softening a back-of-queue heavy is never judged', () => {
startDrill();
zombies.length = 0; G.drill.spawnT = 999;
spawnZombie(0, COURT.L - 3, 'a'); // the urgent one, 3 s out
const urgent = zombies[zombies.length - 1];
urgent.drill = true; urgent.pts = 20; urgent.v = 1.0; urgent.hp = urgent.hpMax = 1;
spawnZombie(2, COURT.L - 18, 'g'); // a brute at the back, 5 hp
const brute = zombies[zombies.length - 1];
brute.drill = true; brute.pts = 80; brute.v = 1.0;
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
ball.x = brute.x; ball.z = brute.z; ball.vx = ball.vy = ball.vz = 0;
update(DT);
assert.ok(zombies.includes(brute), 'setup: the brute survived the hit');
assert.ok(brute.hp < brute.hpMax, 'setup: but is wounded');
assert.equal(G.drill.faults, 0, 'a wound out of turn is NOT a fault — only the kill is judged');
});
test('the multiplier steps with the streak and caps', () => {
startDrill();
const d = G.drill;
d.streak = 0; assert.equal(drillMult(d), 1);
d.streak = TUNE.drillMultEvery; assert.equal(drillMult(d), 2);
d.streak = TUNE.drillMultEvery * 9; assert.equal(drillMult(d), TUNE.drillMultCap, 'capped');
});
test('the best score survives in localStorage', () => {
startDrill();
const [fast] = riggedQueue();
G.drill.score = 777; G.drill.best = 0;
drillKillBody(fast); // any correct kill banks through bankBest
assert.ok(G.drill.best >= 777, 'the run became the best');
assert.ok(Number(localStorage.getItem('drillBest')) >= 777, 'and it is written down');
});
test('the stream keeps arriving, capped and spaced', () => {
const eaten = TUNE.eatenR; TUNE.eatenR = 0;
try {
startDrill();
const seeded = zombies.length;
let peak = 0;
for (let i = 0; i < 18 * 60; i++) { update(DT); peak = Math.max(peak, zombies.length); }
assert.ok(peak > seeded, 'bodies keep arriving: peak ' + peak);
assert.ok(peak <= TUNE.drillMaxOnCourt, 'never past the court cap');
for (let i = 0; i < zombies.length; i++) for (let j = i + 1; j < zombies.length; j++)
assert.ok(Math.abs(zombies[i].x - zombies[j].x) > 0.3 || Math.abs(zombies[i].z - zombies[j].z) > 0.8,
'two bodies ended up on top of each other');
} finally { TUNE.eatenR = eaten; }
});
test('being caught sweeps the court and kills the streak, but the score survives', () => {
drillPlaying();
G.drill.score = 900; G.drill.streak = 6;
killPlayer();
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode');
assert.equal(G.drill.lost, 1, 'it is a loss');
assert.equal(G.drill.streak, 0, 'the streak dies');
assert.equal(G.drill.score, 900, 'the score does not');
assert.equal(zombies.length, 0, 'the court is swept');
assert.equal(ball.active, false, 'and the finishing ball is spent — the court refills up-court');
for (let i = 0; i < 10 * 60; i++) update(DT);
assert.ok(zombies.length > 0, 'then the stream simply resumes');
});
test('a body that walks past you counts as caught, never as a free escape', () => {
drillPlaying();
const runaway = zombies.find(z => z.drill);
assert.ok(runaway, 'setup: the stream is on court');
runaway.x = P.x + 5; runaway.z = COURT.L + 1;
const lost = G.drill.lost;
update(DT);
assert.equal(G.drill.lost, lost + 1, 'it costs the point');
assert.ok(!zombies.includes(runaway), 'and it is gone');
});
test('drillOneShot is a switch, and OFF means the roster HP is intact', () => {
startDrill();
for (let i = 0; i < 30 * 60 && !zombies.some(z => 'gkhde'.includes(z.variant)); i++) update(DT);
const heavy = zombies.find(z => 'gkhde'.includes(z.variant));
if (!heavy) return; // this seed dealt only shamblers in the window
if (TUNE.drillOneShot >= 0.5) assert.equal(heavy.hp, 1, 'one-shot on: everything is glass');
else assert.ok(heavy.hp > 1, 'one-shot off: a ' + heavy.variant + ' keeps its ' + heavy.hp + ' hp');
});
test('endDrill hands the court back', () => {
drillPlaying();
endDrill();
assert.equal(G.drill, null);
assert.equal(zombies.length, 0, 'the drill takes its bodies with it');
assert.ok(startDuel() !== false && G.interlude, 'and an ordinary mode can have the court again');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report