"please make the zombie killable with a single hit for testing" and "remove the fake zombies for now". drillOneShot drops every body on a drill court to 1 hp, applied at spawn to the batch and the eaters alike. Worth knowing what it removes: the roster's HP is what makes the called ORDER matter — a roller is normally seven serves, so being told to take it third means starting on it first. At one hit that planning layer is gone and the sequence is pure recall. Flagged in PROJECT.md so it gets decided on feel rather than left on by drift. drillEaterMax 3 -> 0 switches the plain shamblers off. The mechanism is untouched — spawnEater, the wide entry away from the gang, the never-a-fault rule — so one number brings them back. The eater test now asserts whichever contract the knob describes (at 0: nothing but the puzzle ever appears; above 0: they arrive, capped, always plain shamblers) instead of being deleted and rotting. And the never-a-fault rule gets its own test with a hand-placed body, so the rule the eaters depend on stays covered while they are switched off. 31 tests, 15 seeds clean. Verified live: brute/steward/roller all at 1 hp where the roster says 5/5/7, zero strays over 40s, one aimed serve puts down the roller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
547 lines
27 KiB
JavaScript
547 lines
27 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('drillOneShot drops every body on the drill court to a single hit', () => {
|
|
startDrill();
|
|
assert.ok(TUNE.drillOneShot >= 0.5, 'setup: the testing knob is on');
|
|
for (const z of zombies) assert.equal(z.hp, 1, z.variant + ' should be one-shot, saw hp ' + z.hp);
|
|
// and it really dies to ONE splat-quality ball, roster HP notwithstanding
|
|
const tank = zombies.find(z => z.drill && z.variant === 'k')
|
|
|| zombies.find(z => z.drill && z.variant === 'g') || zombies.find(z => z.drill);
|
|
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 put down a ' + tank.variant);
|
|
});
|
|
|
|
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
|