"one-shot is bad - way too easy - also we're losing all the nice gore animation sprites" and "I noticed the last kill of a game can carry to the spawn of a new zombie of the next round". drillOneShot is off. The second half of that note is the part worth remembering: a 1 hp body never passes through a wound stage, so every skin in ZWOUND — arm off, ribs out, still walking — is skipped and the roster's best art never renders at all. Kept as a switch, never a default. The carry-over was a real unfairness, not a curiosity. A ball keeps pace through a kill and travels UP-COURT, which is exactly where the next batch is born — so the shot that finished your round flew on into bodies that did not exist when you took it, and faulted you on a round you had not been shown yet. The shot that ends a round is spent now, and drillCaught does the same since it also refills the court up-court. Pierce within a batch is untouched: that is honest occlusion you could have read, this was not. Verified with real aimed serves through a whole round: round banks, ball goes inactive, the next batch walks on with 0 faults and a clean sequence, and the gang carries roster HP again (brute 5, ball boy 3). 33 tests, 15 seeds clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
578 lines
29 KiB
JavaScript
578 lines
29 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 { drillTarget, 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 coaching drill: a new mode, so it owes the ownership contract a test ------------------
|
|
|
|
// Run the stream for a while. There is no announcement beat and no between-rounds pause any more —
|
|
// a round is always live once two bodies are on court.
|
|
function drillPlaying(seconds = 1) {
|
|
startDrill();
|
|
for (let i = 0; i < seconds * 60; i++) update(DT);
|
|
}
|
|
// kill a body the way every weapon does — through the shared funnel, via the ball's contact test.
|
|
// Returns false when it is already down: a ball carries through the lane, so a shot at one body can
|
|
// take the one behind it (see PROJECT.md's open question).
|
|
// the gang's mean speed — per-TYPE vMul means a single body is not a fair sample of the ramp
|
|
const avgGangSpeed = () => {
|
|
const g = zombies.filter(z => z.drill);
|
|
return g.reduce((a, z) => a + z.v / (DRILL_TYPES.find(t => t.v === z.variant) || { vMul: 1 }).vMul, 0) / Math.max(1, g.length);
|
|
};
|
|
const drillKillType = v => {
|
|
const zb = zombies.find(z => z.drill && z.variant === v);
|
|
if (!zb) return false;
|
|
for (let i = 0; i < 40 && 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');
|
|
return true;
|
|
};
|
|
|
|
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 round behind');
|
|
});
|
|
|
|
test('the puzzle is WINNABLE: every called TYPE is on the far side, and nothing else is in the gang', () => {
|
|
startDrill();
|
|
const called = G.drill.seq;
|
|
assert.ok(called.length >= 2, 'a combination is called at once');
|
|
const gang = zombies.filter(z => z.drill);
|
|
assert.equal(gang.length, called.length, 'exactly as many bodies in the gang as types called');
|
|
assert.deepEqual(gang.map(z => z.variant).sort(), called.slice().sort(),
|
|
'the gang on court IS the combination — nothing called that is not there');
|
|
for (const z of gang)
|
|
assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1));
|
|
assert.equal(new Set(gang.map(z => z.variant)).size, gang.length, 'a type names exactly one body');
|
|
assert.ok(gang.every(z => !'ab'.includes(z.variant)), 'the plain shambler is never in the alphabet');
|
|
// ...and it stays true as the batch walks in: no new body ever joins the gang mid-round
|
|
for (let i = 0; i < 25 * 60; i++) {
|
|
update(DT);
|
|
for (const z of zombies) if (z.drill)
|
|
assert.ok(G.drill.seq.includes(z.variant), 'an uncalled type joined the gang');
|
|
}
|
|
});
|
|
|
|
test('the eaters obey drillEaterMax — including 0, which is OFF', () => {
|
|
// They are switched off right now ("remove the fake zombies for now") but the mechanism is intact,
|
|
// so this asserts whichever contract the knob currently describes rather than being deleted and
|
|
// rotting. The never-a-fault rule below is checked EITHER WAY, because that is the part that has
|
|
// to still be true on the day they are switched back on.
|
|
const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement
|
|
try {
|
|
startDrill();
|
|
const seen = new Set();
|
|
let peak = 0;
|
|
for (let i = 0; i < 40 * 60; i++) {
|
|
update(DT);
|
|
const live = zombies.filter(z => !z.drill);
|
|
peak = Math.max(peak, live.length);
|
|
for (const z of live) {
|
|
seen.add(z);
|
|
assert.ok('ab'.includes(z.variant),
|
|
'an eater is ALWAYS a plain shambler — that is what keeps a called type unambiguous');
|
|
}
|
|
}
|
|
assert.ok(peak <= TUNE.drillEaterMax, 'never more than ' + TUNE.drillEaterMax + ' at once, saw ' + peak);
|
|
if (TUNE.drillEaterMax === 0)
|
|
assert.equal(seen.size, 0, 'at 0 the court carries nothing but the puzzle');
|
|
else
|
|
assert.ok(seen.size > 0, 'otherwise plain shamblers arrive on their own clock');
|
|
} finally { TUNE.eatenR = eaten; }
|
|
});
|
|
|
|
test('killing a body that is not in the gang is never a fault', () => {
|
|
// The rule the eaters depend on, tested with a hand-placed shambler so it holds whether or not
|
|
// the spawner is switched on.
|
|
drillPlaying();
|
|
const seq = G.drill.seq.slice(), faults = G.drill.faults, step = G.drill.step;
|
|
spawnZombie(P.x + 2, 6, 'a');
|
|
const stray = zombies[zombies.length - 1];
|
|
assert.ok(!stray.drill, 'setup: a hand-placed body is not part of the gang');
|
|
for (let i = 0; i < 40 && zombies.includes(stray); i++) {
|
|
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
|
|
ball.x = stray.x; ball.z = stray.z; ball.vx = ball.vy = ball.vz = 0;
|
|
update(DT);
|
|
}
|
|
assert.ok(!zombies.includes(stray), 'setup: it is down');
|
|
assert.deepEqual(G.drill.seq, seq, 'the combination is untouched');
|
|
assert.equal(G.drill.faults, faults, 'and it costs no fault');
|
|
assert.equal(G.drill.step, step, 'and does not advance the sequence');
|
|
});
|
|
|
|
test('the ramp raises the batch, the walk and the eater rate', () => {
|
|
const eaten = TUNE.eatenR; TUNE.eatenR = 0;
|
|
try {
|
|
startDrill();
|
|
const earlyBatch = G.drill.seq.length;
|
|
const earlyV = avgGangSpeed();
|
|
for (let i = 0; i < 40 * 60; i++) { update(DT); }
|
|
G.drill.t = TUNE.drillRampT; // jump to the top of the ramp
|
|
zombies.length = 0; update(DT); // force a fresh batch at full difficulty
|
|
const lateBatch = G.drill.seq.length, lateV = avgGangSpeed();
|
|
assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch);
|
|
assert.ok(lateV > earlyV * 1.4, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2));
|
|
assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner');
|
|
} finally { TUNE.eatenR = eaten; }
|
|
});
|
|
|
|
test('finishing a batch rolls straight into the next one — no pause, no deal', () => {
|
|
drillPlaying();
|
|
const seq = G.drill.seq.slice();
|
|
for (const bib of seq) drillKillType(bib);
|
|
assert.ok(G.drill.rounds >= 1, 'the round is banked');
|
|
update(DT);
|
|
const gang = zombies.filter(z => z.drill);
|
|
assert.ok(gang.length >= 2, 'a fresh batch is already walking in');
|
|
assert.ok(G.drill.seq.length >= 2, 'with its combination already called');
|
|
assert.equal(G.drill.step, 0, 'from the top');
|
|
for (const z of gang) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised');
|
|
});
|
|
|
|
test('the drill keeps its own count; the tennis ladder stays out of it', () => {
|
|
drillPlaying();
|
|
const games = score.games.slice(), pts = score.pts.slice();
|
|
for (const bib of G.drill.seq.slice()) drillKillType(bib);
|
|
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
|
|
assert.deepEqual(score.pts, pts, '...nor tennis points');
|
|
});
|
|
|
|
test('two out of turn is a double fault, and the survivors are re-called', () => {
|
|
startDrill();
|
|
if (G.drill.seq.length < 3) return; // needs a batch with room to be wrong twice
|
|
const wrong1 = G.drill.seq[G.drill.seq.length - 1]; // the LAST call, killed first
|
|
drillKillType(wrong1);
|
|
assert.equal(G.drill.faults, 1, 'out of turn is a fault');
|
|
assert.equal(G.drill.lost, 0, 'but not the round');
|
|
const wrong2 = G.drill.seq[G.drill.seq.length - 1];
|
|
if (wrong2 !== undefined && G.drill.seq.length > 1) {
|
|
drillKillType(wrong2);
|
|
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
|
|
assert.equal(G.drill.faults, 0, 'and the survivors are re-called with a clean slate');
|
|
assert.deepEqual(G.drill.seq.slice().sort(),
|
|
zombies.filter(z => z.drill).map(z => z.variant).sort(),
|
|
'the new combination is exactly what is still standing');
|
|
}
|
|
});
|
|
|
|
test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => {
|
|
// It used to lock onto the called body wherever the pull pointed, which made the shot unmissable
|
|
// and the fault unreachable — Oliver: "no skills involved at all... there is no penalty".
|
|
drillPlaying();
|
|
const called = G.drill.seq[G.drill.step];
|
|
const target = zombies.find(z => z.drill && z.variant === called);
|
|
assert.ok(target, 'setup: the called body is on court');
|
|
// the wrong body FURTHEST from the called one in x, so the two are genuinely separable — aiming
|
|
// at a body that happens to sit in the same lane proves nothing either way
|
|
const wrong = zombies.filter(z => z.drill && z.variant !== called)
|
|
.sort((a, b) => Math.abs(b.x - target.x) - Math.abs(a.x - target.x))[0];
|
|
if (!wrong || Math.abs(wrong.x - target.x) < 1.5) return; // nothing separable on court this deal
|
|
// ...and point the ray AT it, properly normalised. The old version pinned fy at -0.95 and scaled
|
|
// fx, so it never actually aimed at the body it claimed to.
|
|
const dx = wrong.x - P.x, dz = wrong.z - P.z, L = Math.hypot(dx, dz);
|
|
const ft = flickTarget(P, { fx: dx / L, fy: dz / L, mag: 140 });
|
|
assert.equal(ft.zt && ft.zt.variant, wrong.variant, 'the magnet takes the body you pointed at');
|
|
assert.notEqual(ft.zt && ft.zt.variant, called, 'and never the called one just because it is called');
|
|
});
|
|
|
|
test('a wrong kill never leaves an un-winnable round', () => {
|
|
drillPlaying();
|
|
const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first
|
|
if (G.drill.seq.length > 1 && drillKillType(later)) {
|
|
assert.ok(!G.drill.seq.includes(later), 'the dead type leaves the list rather than blocking it');
|
|
assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)),
|
|
'every type still on the list is still on the court');
|
|
}
|
|
});
|
|
|
|
test('the shot that ends a round cannot carry into the next batch', () => {
|
|
// Oliver: "the last kill of a game can carry to the spawn of a new zombie of the next round". A
|
|
// ball keeps pace through a kill and travels UP-COURT, which is exactly where the next batch is
|
|
// born — so it faulted you on a round you had not been shown yet.
|
|
drillPlaying();
|
|
const seq = G.drill.seq.slice();
|
|
for (const v of seq) drillKillType(v);
|
|
assert.ok(G.drill.rounds >= 1, 'setup: the round completed');
|
|
assert.equal(ball.active, false, 'the finishing shot is spent, not left in flight');
|
|
// let the next batch walk on and confirm it arrives untouched
|
|
for (let i = 0; i < 30; i++) update(DT);
|
|
const gang = zombies.filter(z => z.drill);
|
|
assert.ok(gang.length >= 2, 'the next batch is on court');
|
|
assert.equal(G.drill.faults, 0, 'and it did not inherit a fault from the previous round');
|
|
assert.equal(G.drill.step, 0, 'nor a half-finished sequence');
|
|
});
|
|
|
|
test('being caught also spends the ball, for the same reason', () => {
|
|
drillPlaying();
|
|
ball.active = true; ball.lastHit = 'P';
|
|
killPlayer();
|
|
assert.equal(ball.active, false, 'the court refills up-court; a live ball would meet it');
|
|
});
|
|
|
|
test('drillOneShot is a switch, and OFF means the roster HP is intact', () => {
|
|
// Off by default: one-shot bodies were "way too easy" AND skipped every wound skin in ZWOUND,
|
|
// because a 1 hp body never passes through a damage stage. Asserts whichever way the knob is set
|
|
// rather than being deleted when it was turned off.
|
|
startDrill();
|
|
const tank = zombies.find(z => z.drill && z.variant === 'k')
|
|
|| zombies.find(z => z.drill && z.variant === 'g');
|
|
if (TUNE.drillOneShot >= 0.5) {
|
|
for (const z of zombies) assert.equal(z.hp, 1, z.variant + ' should be one-shot, saw hp ' + z.hp);
|
|
} else if (tank) {
|
|
assert.ok(tank.hp > 1, 'the heavy bodies keep the roster HP: ' + tank.variant + ' at ' + tank.hp);
|
|
// ...and that is what lets it show a wound stage on the way down
|
|
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
|
|
ball.x = tank.x; ball.z = tank.z; ball.vx = ball.vy = ball.vz = 0;
|
|
update(DT);
|
|
assert.ok(zombies.includes(tank), 'one hit does not put down a ' + tank.variant);
|
|
assert.ok(tank.hp < tank.hpMax, 'but it is wounded: ' + tank.hp + '/' + tank.hpMax);
|
|
}
|
|
});
|
|
|
|
test('a batch body that walks past you counts as caught, never as a free escape', () => {
|
|
// Types move at different speeds now, and drill bodies hold their lane — so one in a lane you are
|
|
// not standing in can cross the baseline untouched. Left as a plain despawn that stranded the
|
|
// sequence asking for a body which no longer existed: the un-winnable state the mode forbids.
|
|
drillPlaying();
|
|
const runaway = zombies.find(z => z.drill);
|
|
assert.ok(runaway, 'setup: a batch is on court');
|
|
runaway.x = P.x + 5; // a lane the player is nowhere near
|
|
runaway.z = COURT.L + 1; // ...and over the line
|
|
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');
|
|
for (let i = 0; i < 5 * 60; i++) update(DT);
|
|
assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)),
|
|
'the sequence never names a body that is not on court');
|
|
});
|
|
|
|
test('being caught costs the point and the court, not the run', () => {
|
|
drillPlaying();
|
|
const t0 = G.drill.t;
|
|
killPlayer();
|
|
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode');
|
|
assert.equal(G.drill.lost, 1, 'they take the point instead');
|
|
assert.equal(zombies.length, 0, 'the court is swept — the one thing that interrupts the stream');
|
|
assert.ok(G.drill.t < t0, 'and the ramp backs off');
|
|
for (let i = 0; i < 10 * 60; i++) update(DT);
|
|
assert.ok(zombies.length > 0, 'then the stream simply resumes');
|
|
assert.ok(G.drill.seq.length >= 2, 'and a round is live again');
|
|
});
|
|
|
|
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
|