Oliver: "daniel announcing is maximum annoying" / "don't let the rush in position just let them coming from the back in steady speed.. try to space them not too close" / "when one combination is done just let the new one in.. in a continuous stream that increases in speed and difficulty" / "the rounds can stay, they should be continuous". The round survives as the unit. Every seam around it is deleted: no walk-on to a mark, no announcement beat, no between-rounds pause, no deal. Bodies walk in from the far baseline forever and a round is drawn from whoever is on court — finish one and the next is drawn in the same frame, off the same rolling population. The bodies you were ignoring a second ago are the ones being called now. - Daniel is gone from this mode. Each colour has a pitch; a round announces itself as a four-note figure in half a second (blip()). drillTones 0 mutes it. The recordings stay in assets. - PLACEMENTS is deleted with the marks — nothing rushes anywhere and nothing stops, so there are no shapes to place. - A DRILL BODY HOLDS ITS LANE. updateZombies homes bodies onto the player's x past the net, so they arrived spread and bunched into a knot right in front of you; spawn spacing cannot survive that. Their lane is the puzzle here. - One ramp on TIME SURVIVED drives speed, spawn gap and round length. Speed is stamped per body at spawn, so the ramp reads as newcomers outpacing the ones already walking, not the court accelerating under you. - The empty-court watchdog is gone and nothing replaced it: with a stream there is no state to be stuck in. One line covers every case. 27 tests, rewritten around the stream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
443 lines
22 KiB
JavaScript
443 lines
22 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';
|
|
|
|
// ---- 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 } = await import('./src/drill.js');
|
|
const { TUNE, bossArt } = 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).
|
|
const drillKillBib = bib => {
|
|
const zb = zombies.find(z => z.bib === bib);
|
|
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 stream never stops, is spaced, and a round is always live', () => {
|
|
startDrill();
|
|
assert.ok(zombies.length >= 2, 'seeded, so the first round is callable at once');
|
|
assert.ok(G.drill.seq.length >= 2, 'and a round is already called');
|
|
const seeded = zombies.length;
|
|
// 18 s, deliberately short of the ~28 s it takes a body to walk the court: a test that never
|
|
// shoots WILL eventually be caught, and drillCaught sweeps the court — which is correct behaviour
|
|
// and would make this look like the stream had stalled.
|
|
for (let i = 0; i < 18 * 60; i++) update(DT);
|
|
assert.ok(zombies.length > seeded, 'bodies keep arriving: ' + seeded + ' -> ' + zombies.length);
|
|
assert.ok(zombies.length <= TUNE.drillMaxOnCourt, 'never past the court cap');
|
|
assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'a colour is on court at most once');
|
|
// nothing rushes to a mark and stops: every body carries a real speed and keeps it
|
|
assert.ok(zombies.every(z => z.v > 0), 'every body is still walking');
|
|
// SPACING HAS TO SURVIVE THE WALK IN, not just the spawn. Drill bodies hold their lane (the
|
|
// gore mode's homing onto the player's x bunched them into a knot right in front of you), so
|
|
// this checks every body on court, not only the ones that just arrived.
|
|
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');
|
|
// and the lane a body walks in on is the lane it keeps. Tracked by IDENTITY, not by index —
|
|
// the array shifts as bodies cross the baseline and despawn, so zombies[i] is not the same body
|
|
// six seconds later (this compared two different zombies and read as a 1.5 m drift).
|
|
const lanes = new Map(zombies.map(z => [z, z.x]));
|
|
for (let i = 0; i < 6 * 60; i++) update(DT);
|
|
for (const z of zombies) if (lanes.has(z))
|
|
assert.ok(Math.abs(z.x - lanes.get(z)) < 0.5,
|
|
'a drill body drifted out of its lane: ' + lanes.get(z).toFixed(2) + ' -> ' + z.x.toFixed(2));
|
|
});
|
|
|
|
test('the stream ramps: later arrivals are faster, come sooner, and the round gets longer', () => {
|
|
// eatenR 0 makes the player untouchable — the established soak trick (PROJECT.md). Without it a
|
|
// test that never shoots gets caught, the court is swept, and the measurement is of the reset.
|
|
const eaten = TUNE.eatenR; TUNE.eatenR = 0;
|
|
try {
|
|
startDrill();
|
|
const early = [];
|
|
for (let i = 0; i < 20 * 60; i++) { update(DT); for (const z of zombies) if (!z.__seen) { z.__seen = 1; early.push(z.v); } }
|
|
const earlyRound = G.drill.seq.length;
|
|
G.drill.t = TUNE.drillRampT; // jump to the top of the ramp
|
|
const late = [];
|
|
for (let i = 0; i < 20 * 60; i++) { update(DT); for (const z of zombies) if (!z.__seen) { z.__seen = 1; late.push(z.v); } }
|
|
const avg = a => a.reduce((x, y) => x + y, 0) / Math.max(1, a.length);
|
|
assert.ok(late.length > early.length, 'they arrive more often: ' + early.length + ' -> ' + late.length);
|
|
assert.ok(avg(late) > avg(early) * 1.5, 'and they walk faster: ' + avg(early).toFixed(2) + ' -> ' + avg(late).toFixed(2));
|
|
G.drill.seq = []; update(DT); // draw a round at the top of the ramp
|
|
assert.ok(G.drill.seq.length > earlyRound,
|
|
'and the round is longer: ' + earlyRound + ' -> ' + G.drill.seq.length);
|
|
} finally { TUNE.eatenR = eaten; }
|
|
});
|
|
|
|
test('finishing a round rolls straight into the next one — no pause, no deal', () => {
|
|
drillPlaying();
|
|
const seq = G.drill.seq.slice();
|
|
for (const bib of seq) drillKillBib(bib);
|
|
assert.ok(G.drill.rounds >= 1, 'the round is banked');
|
|
assert.ok(G.drill.seq.length >= 2, 'and the next one is ALREADY called, in the same frame');
|
|
assert.equal(G.drill.step, 0, 'from the top');
|
|
assert.ok(zombies.length > 0, 'off the bodies still walking at you — the court is never cleared');
|
|
});
|
|
|
|
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()) drillKillBib(bib);
|
|
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
|
|
assert.deepEqual(score.pts, pts, '...nor tennis points');
|
|
});
|
|
|
|
test('two wrong bodies is a double fault, and the round is scrubbed', () => {
|
|
drillPlaying();
|
|
const decoy = zombies.find(z => !G.drill.seq.includes(z.bib));
|
|
assert.ok(decoy, 'setup: the court carries a body that was not called');
|
|
drillKillBib(decoy.bib);
|
|
assert.equal(G.drill.faults, 1, 'an uncalled body is a fault');
|
|
assert.equal(G.drill.lost, 0, 'but not the round');
|
|
const wrong = zombies.find(z => z.bib !== G.drill.seq[G.drill.step]);
|
|
if (wrong) {
|
|
drillKillBib(wrong.bib);
|
|
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
|
|
assert.equal(G.drill.faults, 0, 'and a fresh round is called with a clean slate');
|
|
}
|
|
});
|
|
|
|
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 wrong = zombies.find(z => z.bib !== called);
|
|
assert.ok(wrong, 'setup: something else is on court');
|
|
const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 });
|
|
assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it');
|
|
});
|
|
|
|
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 && drillKillBib(later)) {
|
|
assert.ok(!G.drill.seq.includes(later), 'the dead colour leaves the list rather than blocking it');
|
|
assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)),
|
|
'every colour still on the list is still on the 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
|