Triage failed the only test that matters — "i think the normal player does not grasp this" — and the lesson across four drill designs is that every invented rule needs teaching, while the rules the scene already implies need none. So the new mode is the game's own oldest rule: tennis. MATCH TEST in the back room (#match). One game per opponent, real 15/30/40 scoring. Built on top of the duel machinery: G.match runs a permanent interlude, which is already what makes serving tennis-like, hides the rack, quiets the spawn director and wakes racketAI. The one change is in resolveInterlude — a settled point feeds matchPoint instead of ending anything. About 90 lines for a whole mode, because the deepest system in the repo was already built. The score is the body: a quarter of its pool per point won, floored at 1, so a love game walks the racket zombie through every wound stage and the game point fells it. Its points draw your blood — hp--, eaten at zero, matchHeal 2 per game won is how you last. Regicide is inherited: kill the body mid-game and the game banks by hand. The timing is drawn, per Oliver's ask: an approach ring around the incoming ball that kisses a fixed inner ring exactly at the sweet contact distance (gold inside the band), and an apex band beside the toss spanning the same 0.12m keyPressed scores as perfect, with a tick riding the live ball. Both cues use the mechanics' own numbers, so the picture cannot lie. Verified by autopilot: 7 games in 90 simulated seconds, opponents 1..8, pools 4/6/8/10, wounds tracking points exactly. 36 tests, 15 seeds clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
572 lines
27 KiB
JavaScript
572 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: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js');
|
|
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
|
|
// player would, so the tests step a live court
|
|
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
|
|
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
|
|
const { killPlayer } = await import('./src/zombies.js');
|
|
const { drillQueue, drillLegal, 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('the rule announces itself once, and the markers are the legal SET', () => {
|
|
_startDrill(); // the raw one: we WANT the modal this time
|
|
assert.ok(G.modal, 'first drill of a run: the rule gets its one sentence');
|
|
assert.equal(G.modal.title, 'CLEARANCE DRILL');
|
|
G.modal = null;
|
|
endDrill(); _startDrill();
|
|
assert.equal(G.modal, null, 'second drill of the same run: no re-teaching');
|
|
G.modal = null;
|
|
// the legal set: everything within the slack of the head, and never anything beyond it
|
|
zombies.length = 0; G.drill.spawnT = 999;
|
|
for (const [dz, v] of [[6, 1.0], [6.5, 1.0], [16, 1.0]]) { // 6 s, 6.5 s, 16 s out
|
|
spawnZombie(0, COURT.L - dz, 'a');
|
|
const zb = zombies[zombies.length - 1];
|
|
zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1;
|
|
}
|
|
const legal = drillLegal();
|
|
assert.equal(legal.length, 2, 'the 0.5 s tie is IN the marker set (slack ' + TUNE.drillSlack + ')');
|
|
assert.ok(!legal.some(z => Math.abs(arrival(z) - 16) < 0.5), 'the 16 s body is not');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// ---- zombie tennis: one game per opponent, blood only ------------------------------------------
|
|
|
|
const startMatch = () => { const r = _startMatch(); G.modal = null; G.modalQ.length = 0; return r; };
|
|
const oppOf = () => zombies.find(z => z.variant === 'r');
|
|
|
|
test('a match owns the court like every other mode', () => {
|
|
assert.ok(startMatch(), 'a match starts on a clean court');
|
|
assert.ok(G.interlude, 'and runs on a permanent interlude — that is the design');
|
|
startDuel(); // interlude guard catches it
|
|
forceZombies();
|
|
assert.equal(G.horde, null, 'no horde into a match');
|
|
startBossFight(1);
|
|
assert.equal(G.bossFight, 0, 'no boss into a match');
|
|
assert.equal(startDrill(), false, 'no drill into a match');
|
|
assert.equal(startMatch(), false, 'no second match');
|
|
endMatch();
|
|
assert.ok(startDrill(), 'endMatch hands the court back');
|
|
});
|
|
|
|
test('a point won tears a wound stage off the body; only the game point fells it', () => {
|
|
startMatch();
|
|
const zb = oppOf();
|
|
assert.equal(zb.hpMax, TUNE.matchOppHp, 'opponent one carries the base pool');
|
|
for (let k = 1; k <= 3; k++) {
|
|
resolveInterlude('P');
|
|
assert.ok(zombies.includes(zb), 'point ' + k + ': still standing');
|
|
assert.ok(zb.hp < zb.hpMax, '...and visibly wounded (' + zb.hp + '/' + zb.hpMax + ')');
|
|
}
|
|
assert.equal(zb.hp, 1, 'three points in: one stage left');
|
|
resolveInterlude('P'); // the game point
|
|
assert.ok(!zombies.includes(zb), 'the game-winning point IS the kill');
|
|
const next = oppOf();
|
|
assert.ok(next && next !== zb, 'a fresh opponent shambled on');
|
|
assert.equal(next.hpMax, TUNE.matchOppHp + TUNE.matchOppHpPer, 'with a deeper pool');
|
|
assert.equal(G.runGames, 1, 'the ladder banked the game');
|
|
assert.deepEqual(score.pts, [0, 0], 'and the points reset');
|
|
});
|
|
|
|
test('their points draw blood, and the fourth is the run', () => {
|
|
startMatch();
|
|
G.hp = TUNE.playerHp;
|
|
for (let k = 1; k <= 3; k++) {
|
|
resolveInterlude('O');
|
|
assert.equal(G.hp, TUNE.playerHp - k, 'point ' + k + ' cost a wound');
|
|
assert.notEqual(G.mode, 'eaten');
|
|
}
|
|
resolveInterlude('O');
|
|
assert.equal(G.mode, 'eaten', 'blood only — and it runs out the way it always does');
|
|
});
|
|
|
|
test('a game won heals, so a clean player can go the distance', () => {
|
|
startMatch();
|
|
G.hp = 1;
|
|
for (let k = 0; k < 4; k++) resolveInterlude('P');
|
|
assert.equal(G.hp, Math.min(G.hpMax, 1 + TUNE.matchHeal), 'the game paid its heal');
|
|
});
|
|
|
|
test('regicide: killing the body mid-game wins the game outright', () => {
|
|
startMatch();
|
|
const zb = oppOf();
|
|
resolveInterlude('P'); // 15-0, so the score is mid-game
|
|
const games = score.games[0];
|
|
zombies.length = 0; // the body is destroyed by a shot, not by the score
|
|
resolveInterlude('P'); // killZombie's exit lands here with the court empty
|
|
assert.equal(score.games[0], games + 1, 'the game banks');
|
|
assert.deepEqual(score.pts, [0, 0], 'the part-played points reset');
|
|
assert.ok(oppOf(), 'and the next opponent is on');
|
|
});
|
|
|
|
console.log(`\n${passed} tests passed`);
|
|
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|