Testers asked for some control over how their player looks. From behind — the only way you ever see him — there are two things worth controlling, and neither needs a single new asset: the kit and the skin are one hue-bucket pixel pass over the sheets already loaded, cached per frame and built on first draw. The buckets are measured, not guessed, and three neighbours had to be fenced off one at a time: the shirt's warm HIGHLIGHTS (which spattered the man with mud on every dark pick), the drawn BLOOD on the death frames (which turned the pool brown — the worst bug this file could ship), and the women's HAIR, which is the same hue as his shorts and cannot be told apart from it by any rule. Her set is played in whites and has no kit to dye, so it is flagged out and the row says so rather than silently doing nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1609 lines
91 KiB
JavaScript
1609 lines
91 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';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
// ---- 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 state = await import('./src/state.js');
|
|
const { G, P, O, score, ball, setupPoint } = state;
|
|
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap } = await import('./src/gameplay.js');
|
|
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
|
|
const { mirror, netMsg, netTest, netTick } = await import('./src/net.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, parts } = 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 cfg = await import('./src/config.js');
|
|
const { TUNE, bossArt, COURT, racketStage } = cfg;
|
|
const statsMod = await import('./src/stats.js');
|
|
const audio = await import('./src/audio.js');
|
|
const { read: readStats, check: statsCheck } = statsMod;
|
|
|
|
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');
|
|
// a won game now opens the MOP BEAT (TUNE.matchMopT) before the next opponent walks on — plus
|
|
// the boy's walk-off hold (he must LEAVE the court) and possibly the WOUNDS TREATED popup, which
|
|
// freezes the world until tapped. Tests that play across a game boundary step through all of it,
|
|
// dismissing popups the way a player would.
|
|
const passMopBeat = () => { // ...stopping at the walk-on: past it, the next opponent PLAYS
|
|
// the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it
|
|
for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
};
|
|
|
|
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
|
|
// THE KILL FALL: the game point fells the body but no longer bursts it on the same frame —
|
|
// the collapse plays, the prone hold holds, and only then the explosion (Oliver: "it would be
|
|
// better if the explosion would happen after the fall").
|
|
assert.ok(zombies.includes(zb), 'the game point drops the body — the burst waits for the fall');
|
|
assert.ok(G.matchFall && G.matchFall.kill, 'the kill fall is armed');
|
|
assert.ok(G.interlude && G.interlude.introT > 0, 'serving is barred while it plays');
|
|
for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT);
|
|
assert.ok(!zombies.includes(zb), 'the fallen body bursts after the prone hold');
|
|
assert.ok(!(G.match.mopT > 0), '...and the finish still waits for the smoke to be seen');
|
|
for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !(G.match.mopT > 0); i++) update(DT);
|
|
// THE MOP BEAT: the corpse's gore holds the court first — no fresh body until the boy has had
|
|
// his time (TUNE.matchMopT, skipped only on a clean court; the explode() above guarantees gore).
|
|
assert.ok(!oppOf(), 'no fresh opponent on the kill frame — the mop beat owns the court');
|
|
assert.ok(G.match.mopT > 0, 'the beat is armed');
|
|
assert.ok(G.interlude && G.interlude.introT > 0, 'and serving is barred for its whole length');
|
|
// ...and the beat now also WAITS FOR THE BOY TO LEAVE the court before the walk-on
|
|
for (let i = 0; i < (TUNE.matchMopT + 2) * 10 && G.boy && Math.abs(G.boy.x) < 6.5; i++) update(0.1);
|
|
assert.ok(!oppOf(), 'still no opponent while the boy is on the court');
|
|
passMopBeat();
|
|
const next = oppOf();
|
|
assert.ok(next && next !== zb, 'a fresh opponent shambled on after the mop beat');
|
|
assert.equal(next.hpMax, TUNE.matchOppHp, 'same flat pool — the ladder escalates in speed/flub, not hp');
|
|
assert.equal(G.runGames, 1, 'the ladder banked the game');
|
|
assert.deepEqual(score.pts, [0, 0], 'and the points reset');
|
|
});
|
|
|
|
test('the WOUNDS TREATED card lands after the explosion, never on top of it', () => {
|
|
startMatch();
|
|
const zb = oppOf();
|
|
G.hp = G.hpMax - 2; // something for the trainer to treat
|
|
for (let k = 0; k < 4; k++) resolveInterlude('P'); // a straight game
|
|
assert.ok(!G.modal, 'no card while the body is still falling');
|
|
for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT);
|
|
assert.ok(!zombies.includes(zb) && !G.modal, 'no card on the burst frame either');
|
|
for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !G.modal; i++) update(DT);
|
|
assert.ok(G.modal && G.modal.title === 'WOUNDS TREATED', 'the trainer card follows the burst');
|
|
});
|
|
|
|
test('their points draw blood from the MATCH pool, and running dry is the run', () => {
|
|
startMatch();
|
|
assert.equal(G.hp, TUNE.matchPlayerHp, 'the match brings its own, deeper pool');
|
|
resolveInterlude('O');
|
|
assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'a point costs one');
|
|
G.hp = 1;
|
|
resolveInterlude('O');
|
|
assert.equal(G.mode, 'eaten', 'blood only — and it runs out the way it always does');
|
|
});
|
|
|
|
test('a match ball never wounds the body: the score is the only way through', () => {
|
|
startMatch();
|
|
const zb = oppOf();
|
|
// a full-power ball straight into the opponent — the regicide path, now closed for matches
|
|
ball.active = true; ball.lastHit = 'P'; ball.y = 1.2; ball.pow = 1; ball.bounces = 0;
|
|
ball.x = zb.x; ball.z = zb.z; ball.vx = 0; ball.vy = 0; ball.vz = -1;
|
|
for (let i = 0; i < 5; i++) update(DT);
|
|
assert.ok(zombies.includes(zb), 'still standing');
|
|
assert.equal(zb.hp, zb.hpMax, 'and unwounded — "killing by smashing" is out of the match');
|
|
});
|
|
|
|
test('THE DEATH LOOP: a fault before a lost point must not arm second-serve forever', () => {
|
|
// The match bypasses tennis's pointTo — the only thing that ever reset G.serveNum — so the first
|
|
// fault of the match made EVERY later missed serve a double fault: a point, blood, and repeat
|
|
// until dead. Oliver hit it: "when trying to serve after i got hurt i get hurt again and again
|
|
// until i die".
|
|
startMatch();
|
|
G.serveNum = 2; // a fault has burned the first serve
|
|
resolveInterlude('O'); // the point is lost (double fault, passing shot, whatever)
|
|
assert.equal(G.serveNum, 1, 'the NEXT point starts on a first serve');
|
|
resolveInterlude('P');
|
|
assert.equal(G.serveNum, 1, '...whoever won it');
|
|
});
|
|
|
|
test('the match brings its own legs: auto-run works even where autoAssist is 0', () => {
|
|
// The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a
|
|
// racket — the flight recorder showed a swing in every lost point and an x that never moved.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop condition
|
|
try {
|
|
startMatch();
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
G.phase = 'rally';
|
|
P.x = 0; P.z = 23;
|
|
ball.active = true; ball.lastHit = 'O'; ball.x = -3; ball.z = 14; ball.y = 1.4;
|
|
ball.vx = -1; ball.vy = 2; ball.vz = 5; // incoming, wide of him
|
|
const x0 = P.x;
|
|
for (let i = 0; i < 45; i++) update(DT);
|
|
assert.ok(Math.abs(P.x - x0) > 0.5, 'he RUNS for it with no manual input and no coarse pointer: '
|
|
+ x0.toFixed(1) + ' -> ' + P.x.toFixed(1));
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('THE SHORT BALL: the assist runs FORWARD onto a dying return, desktop conditions included', () => {
|
|
// Seven of seven recorded losses: a slow return bounced and died at z 18-20 — outside the
|
|
// intercept zone, so nothing chased it and it rolled dead at his feet (below legal contact
|
|
// height). Doubly broken on desktop: intercept computed reachability with autoAssist (0 there),
|
|
// so its answer was always "run home". Both fixed via assistK() + the anyHit candidate.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop pane condition
|
|
try {
|
|
startMatch();
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
G.phase = 'rally';
|
|
P.x = 0; P.z = 23;
|
|
// a slow short return: incoming, will bounce and die around z ~19
|
|
ball.active = true; ball.lastHit = 'O'; ball.x = 1.5; ball.z = 10; ball.y = 1.6;
|
|
ball.vx = 0.4; ball.vy = 2.2; ball.vz = 5.5; ball.bounces = 0; ball.g = TUNE.gravity;
|
|
const z0 = P.z;
|
|
let minGap = 99, minPz = P.z; // deepest he RAN, read during the chase — the point reset
|
|
// teleports him home after, so the final position proves nothing
|
|
for (let i = 0; i < 2.5 * 60 && ball.active && ball.lastHit === 'O'; i++) {
|
|
update(DT);
|
|
minPz = Math.min(minPz, P.z);
|
|
minGap = Math.min(minGap, Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8));
|
|
}
|
|
assert.ok(minPz < z0 - 3.0, 'he ran FORWARD to meet it: z ' + z0 + ' -> ' + minPz.toFixed(1));
|
|
assert.ok(minGap < 1.6, 'and got the ball into reach (closest ' + minGap.toFixed(1) + ' m)');
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('the match recovers to CENTRE between shots — a wide return must not strand you', () => {
|
|
// The match inherited gore-splatter's depth-only recovery (a sidestep-protection rule) by being
|
|
// G.mode 'splatter' — so one wide return pulled you off centre and you STAYED there, and the next
|
|
// ball to the opposite corner was unreachable before it was struck. "Every second ball is
|
|
// basically unreachable" was this, not CPU strength.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; // headless matchMedia reports desktop: assist off
|
|
try {
|
|
startMatch();
|
|
for (let i = 0; i < 2.0 * 60; i++) update(DT);
|
|
G.phase = 'rally';
|
|
P.x = 4.2; P.z = 22.8; // stranded wide by the last return
|
|
ball.active = true; ball.lastHit = 'P'; ball.vz = -8; ball.z = 8; ball.y = 1; // your ball, outbound: not chasing
|
|
const x0 = P.x;
|
|
for (let i = 0; i < 60; i++) update(DT);
|
|
assert.ok(Math.abs(P.x) < Math.abs(x0) - 0.5,
|
|
'auto-run walks back toward centre: ' + x0.toFixed(1) + ' -> ' + P.x.toFixed(1));
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('the tennis timing cues belong to CLASSIC tennis too, not just the match', () => {
|
|
// Reported three times before it was believed, and it was a one-word gate each time: drawApproachRing
|
|
// and drawApexBand both opened with `if (!G.match ...)`, so the mode whose whole pitch is "if the
|
|
// core tennis is good everything else is good" was the one mode without the cues that make the
|
|
// core tennis readable. Asserted on the SOURCE because these are draw calls with no state to
|
|
// observe — what matters is that neither is gated on the match alone ever again.
|
|
const src = readFileSync(new URL('./src/render.js', import.meta.url), 'utf8');
|
|
for (const fn of ['drawApproachRing', 'drawApexBand']) {
|
|
const body = src.slice(src.indexOf('function ' + fn), src.indexOf("function " + fn) + 900);
|
|
assert.ok(!/if \(!G\.match \|\|/.test(body), `${fn} must not be gated on the match alone`);
|
|
assert.ok(/G\.match \|\| G\.mode === 'tennis'/.test(body), `${fn} must also run in classic tennis`);
|
|
}
|
|
});
|
|
|
|
test('the opponent card is a RAMP: it starts easy and only gets harder', () => {
|
|
// "the players should start easy and get harder over time." That is a property of the table, not
|
|
// of any code, so the table is what gets asserted — a row edited into the wrong slot is otherwise
|
|
// invisible until someone plays ten opponents deep.
|
|
const { matchFoe, TUNE, LADDER } = cfg;
|
|
let lastSpeed = 0, lastWhiff = 1e9;
|
|
// read off the table rather than pinned: the ladder went 12 -> 18 on 2026-08-15 and a hardcoded
|
|
// count here would simply have stopped testing the six new rungs without failing
|
|
for (let n = 1; n <= LADDER; n++) {
|
|
const f = matchFoe(n);
|
|
const speed = (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (f.speed ?? 1);
|
|
const whiff = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, n - 1) * (f.flub ?? 1);
|
|
assert.ok(whiff < lastWhiff, `opponent ${n} (${f.short}) must whiff less than the one before`);
|
|
lastWhiff = whiff;
|
|
// Speed is allowed to ZIGZAG by character — opponent 5 is the RUNNER and opponent 6 paints
|
|
// lines instead of sprinting — so this bans a real regression, not a personality. The whiff
|
|
// rate above is the dial that must fall every single rung; it is the one the ramp rides on.
|
|
if (n > 1) assert.ok(speed > lastSpeed * 0.88,
|
|
`opponent ${n} (${f.short}) must not be a big step BACKWARDS in speed`);
|
|
lastSpeed = speed;
|
|
assert.ok(f.name && f.short && f.line, `opponent ${n} is fully named`);
|
|
assert.ok(f.short.length <= 8, `${f.short} fits the board's 8-char slot`);
|
|
}
|
|
// ...and the opening must actually be gentle: opponent one is the softest thing on the card.
|
|
assert.ok((matchFoe(1).speed ?? 1) < (matchFoe(LADDER).speed ?? 1) * 0.6, 'the shambler is no champion');
|
|
// every rung that has its own family wears it — the 2026-08-15 six included, whose whole point is
|
|
// that the rung and the thing that hunts you in the gore mode are the same person
|
|
for (const [n, fam] of [[3, 'sp'], [5, 'rm'], [7, 'ma'], [9, 'sb'], [10, 'sg'], [12, 'cu'],
|
|
[13, 'ph'], [14, 'hc'], [17, 'dr'], [18, 'ch']])
|
|
assert.equal(matchFoe(n).fam, fam, `rung ${n} wears its own family`);
|
|
// ...and every named family is one the preloader actually loads
|
|
for (let n = 1; n <= LADDER; n++)
|
|
assert.ok(cfg.ZFAMS.includes(matchFoe(n).fam), `rung ${n}'s family is registered in ZFAMS`);
|
|
assert.ok(matchFoe(LADDER).h > 1.85, 'the champion stands taller than a stock body');
|
|
// the cup's final draws the top rung and nothing else: a band pointing past the ladder would
|
|
// wrap through matchFoe's modulo and quietly deal the shambler as THE CHAMPION
|
|
const fin = cfg.CUP_ROUNDS[cfg.CUP_ROUNDS.length - 1];
|
|
assert.deepEqual(fin.band, [LADDER], 'the final is the top of the ladder');
|
|
for (const r of cfg.CUP_ROUNDS) for (const rung of r.band)
|
|
assert.ok(rung >= 1 && rung <= LADDER, `${r.name} draws a rung that exists (${rung})`);
|
|
});
|
|
|
|
test('the first opponent cannot paint lines: its returns are width-clamped', () => {
|
|
// aimError x (1 - 0.55 quality) scattered returns to +-3.5 m, which an auto-run player cannot
|
|
// cover — "still a LOT of unreachable balls". Width is the ladder's third dial now.
|
|
assert.ok(TUNE.matchWideBase < 2.5, 'opponent one is kept inside reachable width');
|
|
assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung');
|
|
});
|
|
|
|
test('an aimed return can be played OUT, but only by mistiming it', () => {
|
|
// The bug this pins: an aimed shot targeted a flat 3.3 m and THEN took err() on top — +-aimError
|
|
// at quality 0 — so a flicked return landed as wide as 5.7 m against a 4.115 m singles line and
|
|
// went out about eight times in ten, while an unaimed one (0 + the same scatter) was always in.
|
|
// Flicking, the mode's one act of skill, was what lost the point. Reach scales with the same
|
|
// quality the scatter shrinks with, so the two can never sum past the line — that sum is the
|
|
// whole rule and it is arithmetic, so assert the arithmetic rather than a sampled rally.
|
|
// The contract is NOT "never out" — that was the overcorrection ("too easy. i must be able to
|
|
// play out"). It is: going out is possible and BOUNDED, it gets rarer as timing improves, and a
|
|
// perfectly timed flick can never do it. Risk decided by skill rather than by a coin toss.
|
|
const line = COURT.W / 2, aimW = TUNE.flickWidth; // doHit's far-court aim width
|
|
const pOut = q => {
|
|
const reach = aimW * (TUNE.flickReach + (1 - TUNE.flickReach) * q);
|
|
const e = TUNE.aimError * (1 - q);
|
|
return e <= 0 ? 0 : Math.max(0, Math.min(1, (e - (line - reach)) / (2 * e)));
|
|
};
|
|
assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%');
|
|
assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)');
|
|
assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out');
|
|
for (let q = 0.05; q <= 1.0001; q += 0.05)
|
|
assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);
|
|
// ...and it must still be worth flicking: perfect timing reaches the corner.
|
|
assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner');
|
|
});
|
|
|
|
test('a wounding point knocks the body down and it gets up MINUS the limb', () => {
|
|
// The beat exists because the wound stage used to POP between points — an arm stopped being there
|
|
// and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears
|
|
// the stage it has BECOME, and the point is handed back only when it is standing again.
|
|
startMatch();
|
|
for (let i = 0; i < 2.0 * 60; i++) update(DT);
|
|
const zb = zombies.find(z => z.variant === 'r');
|
|
assert.equal(racketStage(zb), 0, 'setup: opponent starts unhurt');
|
|
// A quarter off a full pool is 0.75 — still stage 0, so point ONE has no limb to explain. The
|
|
// stage crosses on the second point (0.5) and the third (0.25); that progression is the beat.
|
|
resolveInterlude('P');
|
|
assert.equal(G.matchFall, null, 'point one takes a quarter but crosses nothing');
|
|
resolveInterlude('P'); // ...this one takes the arm
|
|
assert.ok(G.matchFall, 'a stage-crossing point starts the fall');
|
|
assert.equal(G.matchFall.from, 0, 'it collapses as the body it still was');
|
|
update(DT);
|
|
assert.match(zb.showFrame, /fall1$/, 'first frame is the buckle');
|
|
assert.ok(!/w\d/.test(zb.showFrame), 'and it is the UNWOUNDED buckle');
|
|
// ...and it must LIE THERE a moment: a corpse that touches the ground and is instantly upright
|
|
// reads as a stumble, not as something the point did to it. THE RISE ROW IS GONE (2026-08-13,
|
|
// Oliver: "remove the rise frame. the opponent respawns anyway") — the beat ends prone and the
|
|
// body takes its mark in the wound skin.
|
|
let downFrames = 0;
|
|
for (let i = 0; i < (FALL_LEN + 1) * 60 && G.matchFall; i++) {
|
|
update(DT); if (/fall3$/.test(zb.showFrame || '')) downFrames++;
|
|
}
|
|
assert.ok(downFrames > 0.3 * 60, 'it stays down for a beat: ' + (downFrames / 60).toFixed(2) + 's');
|
|
assert.equal(G.matchFall, null, 'the fall ends on its own clock');
|
|
assert.equal(racketStage(zb), 1, '...and it comes back as the one-armed body');
|
|
assert.equal(zb.showFrame, null, 'and hands the sprite back to drawZombie');
|
|
assert.equal(G.serveNum, 1, 'matchReady ran: the next point is armed');
|
|
});
|
|
|
|
test('the fall beats the ball: a passing winner fells the body while the ball still rolls', () => {
|
|
// Oliver: "the zombie falls after the ball is lost although we know this earlier — it could
|
|
// play out as ball lost BECAUSE of the falling." A winner that bounced in beyond the body's
|
|
// provable reach resolves at bounce ONE: the collapse starts, the ball keeps rolling, and the
|
|
// decided-latch makes sure the rally can never bank twice.
|
|
startMatch();
|
|
G.mode = 'splatter'; // the app's intoSplatter() precedes startMatch()
|
|
G.interlude.introT = 0;
|
|
const zb = oppOf();
|
|
zb.hp = 3; // so THIS point crosses a wound stage (3 -> 2 is the arm)
|
|
zb.x = -4; zb.z = 2; // parked far from where the winner lands
|
|
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
|
|
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
const pts = score.pts[0];
|
|
for (let i = 0; i < 40 && !G.matchFall; i++) update(DT);
|
|
assert.ok(G.matchFall, 'the collapse starts at the decided moment, not at the dead ball');
|
|
assert.ok(ball.active, '...and the ball is still rolling past the falling body');
|
|
assert.equal(score.pts[0], pts + 1, 'the point banked at the decision');
|
|
for (let i = 0; i < 5 * 60; i++) update(DT); // the ball dies its ordinary death, the fall plays out
|
|
assert.equal(score.pts[0], pts + 1, '...and never banked a second time');
|
|
assert.ok(!G.match.decided, 'the latch cleared for the next rally');
|
|
});
|
|
|
|
test('an ordinary far winner keeps its ordinary ending, and the match plays on (the soft-lock)', () => {
|
|
// The first shipped version of the early fall latched `decided` AFTER resolveInterlude — and on
|
|
// a point with no stage change matchPoint re-arms the next rally synchronously, so the latch
|
|
// outlived its rally and swallowed every resolution after it: no falls, no points, no serve,
|
|
// ever again. Oliver: "the fall never happens, the game does not continue, the ball is gone."
|
|
startMatch();
|
|
G.mode = 'splatter';
|
|
G.interlude.introT = 0;
|
|
const zb = oppOf(); // full pool: this point crosses NO stage
|
|
zb.x = -4; zb.z = 2;
|
|
const launch = () => {
|
|
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
|
|
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
};
|
|
launch();
|
|
const pts = score.pts[0];
|
|
for (let i = 0; i < 5 * 60 && score.pts[0] === pts; i++) update(DT);
|
|
assert.equal(G.matchFall, null, 'no stage to show: no early fall, the ordinary ending played');
|
|
assert.equal(score.pts[0], pts + 1, 'the point banked once');
|
|
assert.ok(!G.match.decided, 'and the latch is not left armed');
|
|
launch(); // THE NEXT RALLY MUST STILL RESOLVE — the lock test
|
|
for (let i = 0; i < 5 * 60 && score.pts[0] === pts + 1; i++) update(DT);
|
|
assert.equal(score.pts[0], pts + 2, 'the match plays on');
|
|
});
|
|
|
|
test('the menus own the theme, and hand the court back its silence', () => {
|
|
// Oliver, 2026-08-15: "after some games — or returning to the main menu via the close button —
|
|
// the background music does not play any more, but you sometimes still hear the court background
|
|
// noise." loopBed no-ops once a file is playing, so menuMusic() could only ever START the theme,
|
|
// never bring it BACK from the duck start() put it under — and nothing ducked the court's own
|
|
// beds at all. The menu was silent and noisy at once, permanently, from the first run onward.
|
|
const d = audio.bedTest.ducked, THEME = 'background-music.m4a',
|
|
AMB = 'tennis-ambient-loop.wav', ZOM = 'zombie-mode.m4a';
|
|
audio.menuMusic(); // on the menu
|
|
assert.ok(!d.has(THEME), 'the theme plays on the menu');
|
|
audio.menuDuck(true); audio.ambient(); audio.zombieMusic(); // ...a run starts
|
|
assert.ok(d.has(THEME), 'the theme hands the court over');
|
|
assert.ok(!d.has(AMB) && !d.has(ZOM), 'and the court gets its own beds');
|
|
audio.menuMusic(); // ...and the run ends, however it ended
|
|
assert.ok(!d.has(THEME), 'the theme is BACK — this is the bug');
|
|
assert.ok(d.has(AMB) && d.has(ZOM), '...and the court does not follow you into the menu');
|
|
audio.ambient(); audio.zombieMusic(); // ...and the NEXT run is not played in silence
|
|
assert.ok(!d.has(AMB) && !d.has(ZOM), 'a second run sounds like the first');
|
|
// ...but the SPLATTER BED IS THE GORE'S ALONE. Every run of every mode starts the ambience, so
|
|
// un-ducking both court beds there put the zombie music back on in classic tennis for the rest of
|
|
// the session (Oliver: "after playing the splatter gore, the zombie background sound also keeps
|
|
// playing in the normal tennis game"). Leaving the court is one event; arriving is not.
|
|
audio.menuMusic(); // back to the menu after a gore run
|
|
audio.ambient(); // ...and into a game of plain tennis
|
|
assert.ok(!d.has(AMB), 'the ambience belongs to every mode');
|
|
assert.ok(d.has(ZOM), 'the splatter music does NOT follow you into classic tennis');
|
|
audio.menuMusic();
|
|
});
|
|
|
|
test('SPLATTER GORE opens in the gore — no tennis prologue to win first', () => {
|
|
// Oliver, 2026-08-15: "it should not have the normal tennis intro anymore, we should just start
|
|
// right in splatter mode." The transition is not deleted (forceZombies still plays it) — what is
|
|
// gone is it standing between the menu and the mode.
|
|
restartRun();
|
|
assert.equal(G.mode, 'tennis', 'a fresh run starts where every run starts');
|
|
startGore();
|
|
assert.equal(G.mode, 'splatter', 'the mode you picked is the mode you get');
|
|
assert.ok(G.everTransitioned, '...and the prologue can never fire behind it');
|
|
assert.ok(zombies.some(z => z.variant === 'c'), 'the man you came to play is already one of them');
|
|
assert.ok(G.hideO, 'so the tennis opponent is gone from the far court');
|
|
assert.equal(score.server, 'P', 'and the serve is yours');
|
|
startGore();
|
|
assert.equal(zombies.filter(z => z.variant === 'c').length, 1, 'a second call is a no-op, not a second corpse');
|
|
});
|
|
|
|
test('the gore economy: barrels stop being income, and the late court keeps getting worse', () => {
|
|
// "in splatter gore it now gets too easy to just play through... maybe we should reduce the
|
|
// barrels again — pretty early in the game every won game gives a barrel." At 3/8 the gap hit ONE
|
|
// at game 16 and stayed, so the back half of a run paid a weapon per game won. The floor is the
|
|
// fix, and it is the property worth pinning: a barrel is a reward, never an income.
|
|
const gapAt = g => { G.runGames = g; return barrelGap(); };
|
|
for (const g of [0, 5, 12, 20, 40, 120])
|
|
assert.ok(gapAt(g) >= 2, `game ${g}: a barrel is never one-per-game (gap ${gapAt(g)})`);
|
|
assert.ok(gapAt(40) <= gapAt(0), 'the late game still earns them faster than the early one');
|
|
assert.equal(gapAt(400), TUNE.barrelFloor, '...down to the floor and no further');
|
|
// ...and the pressure keeps climbing where the barrels stop
|
|
const capAt = g => { G.runGames = g; return machCap(); };
|
|
assert.equal(capAt(0), TUNE.machMax, 'game one gets the old two guns');
|
|
assert.ok(capAt(40) > capAt(0), 'a long run faces more of them');
|
|
assert.equal(capAt(400), TUNE.machMaxCap, '...to a ceiling, because the court still has to read');
|
|
// the two ramps that used to stop dead around game 25 now run past it
|
|
assert.ok(1 + Math.min(TUNE.zSpeedMax, 30 * TUNE.zSpeedPerGame)
|
|
> 1 + Math.min(TUNE.zSpeedMax, 22 * TUNE.zSpeedPerGame), 'shamblers keep gaining speed past game 22');
|
|
assert.equal(Math.min(TUNE.nastyMax, 40 * TUNE.nastyPerGame), 1,
|
|
'and by the end every body that walks out is off the advanced roster');
|
|
G.runGames = 0;
|
|
});
|
|
|
|
test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
|
|
const { CUP_ROUNDS } = cfg;
|
|
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
|
G.mode = 'splatter';
|
|
const d = G.match;
|
|
assert.ok(d.cup, 'the cup rides the match machinery');
|
|
for (let round = 0; round < CUP_ROUNDS.length; round++) {
|
|
const R = CUP_ROUNDS[round];
|
|
assert.equal(d.cup.round, round, 'on ' + R.name);
|
|
assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')');
|
|
const zb = oppOf();
|
|
assert.equal(zb.hpMax, TUNE.matchOppHp * R.need, 'the pool spans the whole round');
|
|
for (let g = 1; g <= R.need; g++) {
|
|
for (let k = 0; k < 4; k++) resolveInterlude('P');
|
|
G.modal = null; G.modalQ.length = 0; // the trainer's popup, tapped away
|
|
if (g < R.need)
|
|
assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on');
|
|
}
|
|
// the round-winning game arms the KILL FALL: the body drops first, bursts after the hold
|
|
assert.ok(G.matchFall && G.matchFall.kill, R.name + ': the kill fall is armed');
|
|
for (let i = 0; i < 4 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill');
|
|
if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on
|
|
}
|
|
assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds');
|
|
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
|
|
});
|
|
|
|
test('THE DRAW: a bracket settled on screen is the bracket that is played', () => {
|
|
// The screen would be worse than nothing if it dealt a different tournament from the one it just
|
|
// showed you settling (main.buildBracket hands its rungs straight to startMatch). The guard is
|
|
// the other half: a rung that is not in the round's band falls back to an honest roll rather
|
|
// than dealing THE CHAMPION in round one.
|
|
const { CUP_ROUNDS, LADDER } = cfg;
|
|
const drawn = CUP_ROUNDS.map(r => r.band[r.band.length - 1]);
|
|
_startMatch(true, drawn); G.modal = null; G.modalQ.length = 0;
|
|
assert.equal(G.match.opp, drawn[0], 'round one walks on the body the screen landed on');
|
|
assert.deepEqual(G.match.cup.draw, drawn, 'and the rest of the draw rides with the run');
|
|
endMatch();
|
|
// a nonsense draw is refused per round, not trusted
|
|
_startMatch(true, [LADDER, LADDER, LADDER, LADDER]); G.modal = null; G.modalQ.length = 0;
|
|
assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp),
|
|
'a rung round one cannot contain is rolled instead of dealt');
|
|
endMatch();
|
|
// ...and no draw at all is the old behaviour, which the #cup back door still uses
|
|
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
|
assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp), 'no draw: the round rolls its own');
|
|
endMatch();
|
|
});
|
|
|
|
test('cup rallies never strand the latch: no-crossing points, the fall, and the mid-round game', () => {
|
|
// Oliver's second lock: in the quarter-final, matchFallWillPlay still divided by 4 while
|
|
// matchPoint divided by 4*need — it promised a fall on a point that crossed nothing, the
|
|
// no-fall path re-armed synchronously, and the after-set latch went stale ("basically like
|
|
// freeze from before"). The latch is set BEFORE the resolve now (the swallow exempts the
|
|
// resolution in progress via G.matchEarly), and willFall mirrors the cup divisor exactly.
|
|
const { CUP_ROUNDS } = cfg;
|
|
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
|
G.mode = 'splatter';
|
|
const d = G.match;
|
|
G.interlude.introT = 0;
|
|
for (let k = 0; k < 4; k++) resolveInterlude('P'); // round one falls to the ordinary path
|
|
G.modal = null; G.modalQ.length = 0;
|
|
passMopBeat();
|
|
assert.equal(d.cup.round, 1, 'on the quarter-final');
|
|
const zb = oppOf();
|
|
const launch = () => {
|
|
zb.x = -4; zb.z = 2;
|
|
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
|
|
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
const before = score.pts[0] + score.games[0] * 10;
|
|
for (let i = 0; i < 8 * 60 && score.pts[0] + score.games[0] * 10 === before && !G.matchFall; i++) update(DT);
|
|
for (let i = 0; i < 6 * 60 && (G.matchFall || G.modal); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
};
|
|
launch(); // 8 -> 7: crosses nothing (the old mispredict)
|
|
assert.equal(zb.hp, 7, 'point one chipped the pool');
|
|
assert.ok(!d.decided, 'no stale latch after a no-crossing point');
|
|
launch(); // 7 -> 6: still nothing
|
|
launch(); // 6 -> 5: the arm comes off, mid-flight
|
|
assert.ok(!d.decided, 'the fall beat ended and released its latch');
|
|
launch(); // the game point, mid-round: same body plays on
|
|
assert.ok(zombies.includes(zb), 'game one banked, the quarter-final body stands');
|
|
assert.equal(score.games[0], 2, 'the game went up');
|
|
launch(); // game two opens: the match is ALIVE
|
|
assert.equal(score.pts[0], 1, 'the next rally resolved — no freeze');
|
|
});
|
|
|
|
test('service alternates by game, and the opponent actually serves', () => {
|
|
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
|
|
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return
|
|
// timing is half the sport and it was never asked for.
|
|
startMatch();
|
|
for (let i = 0; i < 2.0 * 60; i++) update(DT);
|
|
assert.equal(score.server, 'P', 'you open the match');
|
|
for (let pt = 0; pt < 4; pt++) { // four straight points is the game
|
|
resolveInterlude('P');
|
|
for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish
|
|
}
|
|
assert.equal(score.server, 'O', 'their serve next game');
|
|
passMopBeat(); // the mop beat (and the boy's exit) come first
|
|
// the opponent stands behind ITS baseline to serve, not on the receiving mark
|
|
const rz = zombies.find(z => z.variant === 'r');
|
|
assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2));
|
|
// ...and it tosses and strikes on its own, once the walk-on beat is over
|
|
let served = false;
|
|
for (let i = 0; i < 8 * 60 && !served; i++) { update(DT); if (ball.active && ball.lastHit === 'O') served = true; }
|
|
assert.ok(served, 'the opponent serves without the player touching anything');
|
|
});
|
|
|
|
test('a lost point never fells the opponent', () => {
|
|
// The fall is what a WOUND looks like. A point the player loses costs blood, not a limb, and a
|
|
// collapse there would read as the opponent being hurt by winning.
|
|
startMatch();
|
|
for (let i = 0; i < 2.0 * 60; i++) update(DT);
|
|
const hp = G.hp;
|
|
resolveInterlude('O');
|
|
assert.equal(G.matchFall, null, 'their point starts no fall');
|
|
assert.equal(G.hp, hp - 1, '...it draws your blood instead');
|
|
});
|
|
|
|
test('the placement mark fades on its own clock', () => {
|
|
// The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the
|
|
// state machine owes is that a mark never lingers — a permanent ring would read as a target the
|
|
// player must hit rather than feedback on where the last shot went.
|
|
startMatch();
|
|
for (let i = 0; i < 2.0 * 60; i++) update(DT);
|
|
G.placeMark = { x: 2, z: 4, t: 0.8 };
|
|
for (let i = 0; i < 60; i++) update(DT);
|
|
assert.equal(G.placeMark, null, 'gone within a second');
|
|
});
|
|
|
|
test('REACHABILITY: an auto-run player can cover the first opponent\'s returns', () => {
|
|
// The test this issue always deserved and never had — "unreachable balls" came back TWICE because
|
|
// the suite only asserted the config dials, never the experience. This plays the match headless
|
|
// with the same mediocre autopilot the browser uses (auto-run + a fixed tap window, no placement)
|
|
// and asserts that the returns actually present themselves: a ball that never comes within volley
|
|
// reach of an auto-run player is unreachable BY CONSTRUCTION, whatever the knobs claim.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
startMatch();
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
const returns = [];
|
|
let cur = null;
|
|
for (let i = 0; i < 90 * 60 && G.mode !== 'eaten'; i++) {
|
|
G.modal = null; G.modalQ.length = 0;
|
|
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
|
|
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
|
|
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
|
|
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
|
|
if (!cur) cur = { minD: 99 };
|
|
cur.minD = Math.min(cur.minD, d);
|
|
if (d < 2.9 && d > 2.2) keyPressed('Space');
|
|
}
|
|
if (cur && (!ball.active || ball.lastHit !== 'O')) { returns.push(cur.minD); cur = null; }
|
|
update(DT);
|
|
}
|
|
assert.ok(returns.length >= 10, 'the soak saw a real rally sample: ' + returns.length + ' returns');
|
|
const unreachable = returns.filter(d => d > 2.6).length; // never inside volley reach
|
|
assert.ok(unreachable / returns.length <= 0.15,
|
|
unreachable + ' of ' + returns.length + ' returns never came within reach of an auto-run player');
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('the match racket is longer, and only the match racket', () => {
|
|
assert.ok(TUNE.matchReach > 1.2, 'match contact reach is widened — the recorder showed misses by centimetres');
|
|
// the gore modes' contact stays untouched: matchReach only multiplies inside G.match
|
|
});
|
|
|
|
test('the ladder pace dial exists and starts below full pace', () => {
|
|
assert.ok(TUNE.matchOppPace < 1, 'opponent one returns slower than the physics maximum');
|
|
assert.ok(TUNE.matchOppPacePer > 0, 'and earns pace back per rung');
|
|
});
|
|
|
|
test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => {
|
|
// tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still
|
|
// said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot
|
|
// line (COURT.L + 0.3), so the moment the toss went up the game scored "its passing shot ends the
|
|
// duel": a point and blood per TAP. serveNum was a red herring; this was the loop Oliver hit twice.
|
|
startMatch();
|
|
for (let i = 0; i < 2.0 * 60; i++) update(DT); // ride out the intro beat — it blocks serving
|
|
const hp0 = G.hp, pts0 = score.pts.slice();
|
|
ball.lastHit = 'O'; ball.active = false; // the zombie touched the last rally ball
|
|
assert.ok(P.z > COURT.L + 0.3, 'setup: the server stands behind the passing-shot line');
|
|
keyPressed('Space'); // the toss goes up
|
|
assert.equal(G.phase, 'toss', 'the toss is up');
|
|
for (let i = 0; i < 40; i++) update(DT); // two thirds of a second of flight
|
|
assert.equal(G.hp, hp0, 'no blood for tossing a ball');
|
|
assert.deepEqual(score.pts, pts0, 'and no point changes hands');
|
|
assert.equal(ball.lastHit, 'P', 'the toss is the server\'s ball');
|
|
});
|
|
|
|
test('every point starts with the receiver on its mark', () => {
|
|
startMatch();
|
|
const rz = oppOf();
|
|
rz.x = 6; rz.z = 12; // stranded mid-court by the last rally
|
|
resolveInterlude('P');
|
|
assert.ok(Math.abs(rz.x) < 3 && rz.z <= 1.5,
|
|
'reset to the receiver spot, not wherever the rally left it: x=' + rz.x.toFixed(1) + ' z=' + rz.z.toFixed(1));
|
|
assert.equal(rz.x, O.x, 'on the exact mark the tennis branch computed for the receiver');
|
|
});
|
|
|
|
test('the ladder escalates in speed and reliability, not in hp', () => {
|
|
// pure arithmetic on the knobs racketAI reads — pinned so a future tuning pass cannot silently
|
|
// flatten the ladder again
|
|
assert.ok(TUNE.matchSpeedBase > 1, 'opponent one is already quicker than the duel lumber');
|
|
assert.ok(TUNE.matchSpeedPer > 0, 'each opponent is faster');
|
|
assert.ok(TUNE.matchFlub < TUNE.racketFlub, 'the match whiffs less than the duel from the start');
|
|
assert.ok(TUNE.matchFlubKeep < 1, 'and less again per rung');
|
|
});
|
|
|
|
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');
|
|
for (let i = 0; i < 5 * 60 && G.matchFall; i++) update(DT); // the heal waits for the burst now
|
|
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');
|
|
passMopBeat();
|
|
assert.ok(oppOf(), 'and the next opponent is on');
|
|
});
|
|
|
|
test('the match measures OPPONENTS FELLED, banked as its own kind of run', () => {
|
|
localStorage.removeItem('splatterRuns');
|
|
startMatch();
|
|
for (let g = 0; g < 2; g++) { // two games = two bodies down
|
|
for (let k = 0; k < 4; k++) resolveInterlude('P');
|
|
passMopBeat(); // each corpse gets its mop beat
|
|
}
|
|
assert.equal(G.match.opp, 3, 'on the third rung');
|
|
G.hp = 1; resolveInterlude('O');
|
|
assert.equal(G.mode, 'eaten', 'bled out');
|
|
assert.equal(G.match.felled, 2, 'the summary counts bodies, not games');
|
|
assert.ok(G.newBest, 'first run of the day is a record');
|
|
// a shorter second run must NOT overwrite the mark, and must know what it is chasing
|
|
G.mode = 'splatter'; endMatch(); startMatch();
|
|
for (let k = 0; k < 4; k++) resolveInterlude('P');
|
|
passMopBeat(); // no points settle while the boy owns the court
|
|
G.hp = 1; resolveInterlude('O');
|
|
assert.equal(G.match.felled, 1);
|
|
assert.equal(G.match.best, 2, 'the mark to beat is shown');
|
|
assert.ok(!G.newBest, 'and one body does not beat two');
|
|
// ...and none of it lands in the gore mode's stats, which is where it all used to go: a felled
|
|
// opponent is a kill credited to 'bomb', and three of them are not a massacre.
|
|
const runs = JSON.parse(localStorage.getItem('splatterRuns'));
|
|
assert.ok(runs.every(r => r.m === 'z'), 'both runs are tagged as match runs');
|
|
assert.equal(readStats('all').kills, 0, 'the gore screen shows nothing from a match');
|
|
assert.equal(readStats('all', undefined, 'z').longest, 2, '...and the match view knows the best');
|
|
G.mode = 'splatter'; endMatch();
|
|
});
|
|
|
|
// ---- classic tennis: the mode where nothing turns ---------------------------------------------
|
|
|
|
test('classic tennis: a game is not a set, and six with a two-game lead is', () => {
|
|
G.mode = 'tennis'; G.classic = true; G.everTransitioned = false;
|
|
score.games = [5, 4];
|
|
assert.equal(classicGame('P'), false, '5-4 is not a set — advancePoint has not banked the sixth yet');
|
|
score.games = [6, 5];
|
|
assert.equal(classicGame('P'), false, 'nor is 6-5: the two-game lead is the rule');
|
|
assert.deepEqual(G.sets, [0, 0]);
|
|
score.games = [7, 5];
|
|
assert.equal(classicGame('P'), false, '7-5 IS a set, but not the match');
|
|
assert.deepEqual(G.sets, [1, 0], 'the set is banked');
|
|
assert.deepEqual(score.games, [0, 0], 'and the games reset under it');
|
|
assert.equal(G.done, null, 'one set is not a match');
|
|
});
|
|
|
|
test('the toss strike has stakes: flush is fast, a miss is slow, the old free window is gone', () => {
|
|
// the serve-agency pass: apexSnap/apexFall are TUNE knobs and deliberately tight, so timing
|
|
// must show up in the ball's actual pace (the fault economy then prices the aimed serve)
|
|
G.mode = 'tennis'; G.classic = true; G.match = null; G.interlude = false; G.done = null;
|
|
const strike = dy => {
|
|
G.phase = 'pretoss'; ball.active = false; score.server = 'P'; P.pose = 'idle';
|
|
keyPressed('Space'); // toss
|
|
assert.equal(G.phase, 'toss', 'the tap tossed');
|
|
ball.y = serveApexY() - dy; ball.vy = 0.1; // park the ball dy below the apex
|
|
keyPressed('Space'); // strike
|
|
assert.equal(G.phase, 'rally', 'the strike fired');
|
|
return Math.hypot(ball.vx, ball.vy, ball.vz);
|
|
};
|
|
const flush = strike(0);
|
|
const oldFree = strike(0.1); // inside the old 0.12 snap, outside the new one
|
|
const missed = strike(0.4);
|
|
assert.ok(flush > missed * 1.2, `a flush strike is materially faster (${flush.toFixed(1)} vs ${missed.toFixed(1)})`);
|
|
assert.ok(oldFree < flush - 0.5, 'the 0.12 m free-perfect window no longer exists');
|
|
});
|
|
|
|
test('classic tennis: the second set ends the run, with no corpse', () => {
|
|
G.mode = 'tennis'; G.classic = true;
|
|
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14; G.classicWon = 12;
|
|
assert.equal(classicGame('P'), true, 'the match is over');
|
|
assert.ok(G.done && G.done.won, 'and you won it');
|
|
assert.notEqual(G.mode, 'eaten', 'nobody died — this is the other ending');
|
|
const runs = JSON.parse(localStorage.getItem('splatterRuns'));
|
|
const r = runs[runs.length - 1];
|
|
assert.equal(r.m, 'c', 'banked as a classic run');
|
|
assert.equal(r.dead, 0, 'a match WON is not a death — that is how wins are counted');
|
|
// GAMES YOU WON, not sets: it is what classic's world board ranks, because every win is two sets
|
|
// and a board of nothing but 2s is a wall of ties. Nothing ever displayed the old value.
|
|
assert.equal(r.s, 12, 'games won ride in the second headline slot');
|
|
assert.equal(statsMod.BOARDS.c.score(r), 12, "...and that is the classic board's number");
|
|
});
|
|
|
|
test('classic tennis: losing the match ends it the same way, and counts as a loss', () => {
|
|
G.mode = 'tennis'; G.classic = true;
|
|
G.sets = [0, 1]; score.games = [0, 6]; G.runGames = 12;
|
|
assert.equal(classicGame('O'), true);
|
|
assert.ok(G.done && !G.done.won, 'beaten');
|
|
const runs = JSON.parse(localStorage.getItem('splatterRuns'));
|
|
assert.equal(runs[runs.length - 1].dead, 1, 'a match lost IS the run ending badly');
|
|
});
|
|
|
|
test('classic wears the match feel, and the difficulty table is a ladder', () => {
|
|
// THE RALLY MODES SHARE ONE PLAYER FEEL: classic gets the match's assist floor (with matchReach
|
|
// and the short-ball chase riding the same rallyFeel() switch) — "in classic tennis I tend to
|
|
// miss balls way more often" was these dials being match-only.
|
|
G.classic = false; G.match = null;
|
|
assert.ok(!rallyFeel(), 'gore alone keeps the old feel');
|
|
G.classic = true;
|
|
assert.ok(rallyFeel(), 'classic is a rally mode');
|
|
assert.ok(assistK() >= TUNE.matchAssist, 'classic gets the match assist floor');
|
|
G.classic = false;
|
|
// ...and DIFFS climbs monotonically on every dial, with MEDIUM pinned dial-for-dial to the
|
|
// shipped TUNE values so the middle chip can never drift from what "no difficulty" played like.
|
|
// (The first cut scaled only flub/cap/speed and Oliver felt no difference — reaction, reach,
|
|
// qMin and serve are the dials a player actually reads as skill.)
|
|
const [e, m, h] = cfg.DIFFS;
|
|
assert.ok(e.flub > m.flub && m.flub > h.flub, 'easier flubs more');
|
|
assert.ok(e.quality < m.quality && m.quality < h.quality, 'harder hits better');
|
|
assert.ok(e.speed < m.speed && m.speed < h.speed, 'harder runs faster');
|
|
assert.ok(e.reaction > m.reaction && m.reaction > h.reaction, 'harder reads the shot sooner');
|
|
assert.ok(e.reach < m.reach && m.reach < h.reach, 'harder swings a longer racket');
|
|
assert.ok(e.qMin <= m.qMin && m.qMin < h.qMin, 'harder raises the quality floor');
|
|
assert.ok(e.serve < m.serve && m.serve < h.serve, 'harder serves better');
|
|
assert.ok(e.smart <= m.smart && m.smart < h.smart, 'harder plays the open court');
|
|
assert.ok(e.recover <= m.recover && m.recover < h.recover, 'harder recovers to centre faster');
|
|
assert.equal(m.smart, 0, 'MEDIUM aims at random, as the shipped classic did');
|
|
assert.equal(m.recover, 0.5, 'MEDIUM keeps the shipped recovery jog');
|
|
assert.equal(m.flub, TUNE.classicFlub, 'MEDIUM flub IS classicFlub');
|
|
assert.equal(m.quality, TUNE.classicQualityMax, 'MEDIUM quality IS classicQualityMax');
|
|
assert.equal(m.speed, 1.0, 'MEDIUM leaves aiSpeed alone');
|
|
assert.equal(m.qMin, TUNE.aiQualityMin, 'MEDIUM floor IS aiQualityMin');
|
|
assert.equal(m.reaction, TUNE.aiReaction, 'MEDIUM reaction IS aiReaction');
|
|
assert.equal(m.reach, TUNE.aiReach, 'MEDIUM reach IS aiReach');
|
|
assert.equal(m.serve, 1.0, 'MEDIUM leaves the serve alone');
|
|
});
|
|
|
|
// ---- NETPLAY (src/net.js + beats.startNetVs). The shot is the sync unit and THE RECEIVER OF A
|
|
// FLIGHT IS ITS ONLY JUDGE — these pin both halves of that rule, with netTest.sent standing in
|
|
// for the socket so the wire traffic is a plain array the assertions can read.
|
|
function freshNet(side) {
|
|
restartRun();
|
|
G.classic = true; // netplay rides the classic tennis machinery
|
|
assert.ok(startNetVs(), 'net vs takes a clean court');
|
|
netTest.sent = [];
|
|
netMsg({ t: 'start', side }); // paired: 'A' hosts and serves first
|
|
}
|
|
|
|
test('netplay: the vs match owns the court both ways', () => {
|
|
assert.ok(_startMatch(), 'setup: the match takes the court');
|
|
assert.ok(!startNetVs(), 'netplay refuses while the match owns it');
|
|
endMatch();
|
|
assert.ok(startNetVs(), 'and takes it once the match is gone');
|
|
assert.ok(!_startMatch(), 'the match refuses while netplay owns the court');
|
|
assert.ok(!startDrill(), 'the drill refuses too');
|
|
startDuel();
|
|
assert.equal(G.interlude, false, 'and no duel can begin');
|
|
endNetVs();
|
|
assert.equal(G.net, null, 'endNetVs hands the court back');
|
|
});
|
|
|
|
test('netplay: the mirror is a half-turn about the net and its own inverse', () => {
|
|
const s = { x: 1.3, y: 2.2, z: 24.3, vx: -3.2, vy: 4.1, vz: -28 };
|
|
const m = mirror(s);
|
|
assert.ok(s.z > COURT.netZ && m.z < COURT.netZ, 'a near-baseline strike lands on the far half');
|
|
assert.ok(m.vz > 0, 'travelling away becomes travelling toward');
|
|
const rt = mirror(m);
|
|
for (const k of ['x', 'y', 'z', 'vx', 'vy', 'vz'])
|
|
assert.ok(Math.abs(rt[k] - s[k]) < 1e-9, `two mirrors are the identity (${k})`);
|
|
});
|
|
|
|
test('netplay: a remote serve arrives mirrored and arms the receiver as its judge', () => {
|
|
freshNet('B'); // they host, they serve
|
|
assert.equal(score.server, 'O', 'the host serves first on both scoreboards');
|
|
// their serve event, in THEIR coordinates: struck from behind their near baseline
|
|
netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true });
|
|
assert.ok(ball.active && ball.lastHit === 'O', 'their ball, live on this court');
|
|
assert.ok(ball.z < COURT.netZ, 'launched from the far half');
|
|
assert.ok(ball.vz > 0, 'and travelling toward the receiver');
|
|
assert.equal(G.phase, 'rally');
|
|
assert.ok(G.wasServe, 'the receiver\'s own service-box machinery is armed');
|
|
});
|
|
|
|
test('netplay: the receiver judges the flight — an untouched winner scores and goes over the wire', () => {
|
|
freshNet('B');
|
|
// a legal rally ball from their side (their frame): lands mid-court, never swung at
|
|
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
|
|
for (let i = 0; i < 900 && ball.active; i++) update(DT);
|
|
assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge');
|
|
assert.deepEqual(score.pts, [0, 1], 'their untouched winner is their point');
|
|
const pt = netTest.sent.find(m => m.t === 'pt');
|
|
assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone, winner relative to sender');
|
|
});
|
|
|
|
test('netplay: an outbound ball is picture — this phone never judges its own shot', () => {
|
|
freshNet('A');
|
|
G.phase = 'rally';
|
|
ball.active = true; ball.collected = false; ball.bounces = 0;
|
|
ball.x = 0; ball.y = 1.5; ball.z = 20; ball.vx = 8; ball.vy = 3; ball.vz = -16; ball.g = 9.2;
|
|
ball.lastHit = 'P'; G.wasServe = false; // a rally ball flying well OUT — 8 m/s of sideways
|
|
const pts = JSON.stringify(score.pts);
|
|
for (let i = 0; i < 240; i++) update(DT);
|
|
assert.equal(JSON.stringify(score.pts), pts, 'no local verdict on an outbound flight');
|
|
assert.ok(!netTest.sent.some(m => m.t === 'pt' || m.t === 'fault'), 'and none was sent');
|
|
netMsg({ t: 'pt', win: 'me', reason: 'out' }); // their call arrives instead
|
|
assert.deepEqual(score.pts, [0, 1], 'their call, this scoreboard');
|
|
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
|
|
});
|
|
|
|
test('netplay: the toss travels — the receiver watches the serve coming', () => {
|
|
freshNet('A'); // this phone serves: its toss must go over the wire
|
|
keyPressed('Space');
|
|
assert.equal(G.phase, 'toss', 'the tap tossed');
|
|
assert.ok(netTest.sent.some(m => m.t === 'toss'), 'the toss is an event');
|
|
freshNet('B'); // ...and on the receiving phone
|
|
netMsg({ t: 'toss' });
|
|
assert.equal(G.phase, 'toss', 'their toss is a live toss here');
|
|
assert.ok(ball.active && ball.lastHit === 'O' && ball.vy > 0, 'a real ball rising over O');
|
|
assert.ok(ball.z < COURT.netZ, 'on the far half');
|
|
for (let i = 0; i < 300 && G.phase === 'toss'; i++) update(DT); // nobody strikes it
|
|
assert.equal(G.phase, 'pretoss', 'an uncaught remote toss falls back by plain physics');
|
|
netMsg({ t: 'toss' }); // the re-toss, then the strike over it
|
|
netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true });
|
|
assert.equal(G.phase, 'rally', 'the strike lands cleanly over the replayed toss');
|
|
assert.ok(ball.vz > 0, 'and the serve is inbound');
|
|
});
|
|
|
|
test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s second serve', () => {
|
|
freshNet('B'); // they serve — long, out of the box
|
|
netMsg({ t: 'shot', x: 0.5, y: 2.6, z: 24.3, vx: 0, vy: 1, vz: -35, g: 9.2, pow: 1, serve: true });
|
|
for (let i = 0; i < 300 && ball.active; i++) update(DT);
|
|
assert.equal(G.serveNum, 2, 'the receiver\'s copy of the point advances to second serve');
|
|
assert.ok(netTest.sent.some(m => m.t === 'fault'), 'the fault went to their phone');
|
|
assert.deepEqual(score.pts, [0, 0], 'a first fault is not a point');
|
|
freshNet('A'); // ...and on the server phone the same message lands
|
|
G.serveNum = 1; G.phase = 'rally'; ball.active = true; ball.lastHit = 'P'; G.wasServe = true;
|
|
netMsg({ t: 'fault' });
|
|
assert.equal(G.serveNum, 2, 'their call re-armed the second serve');
|
|
// THE CALLED BALL PLAYS OUT (2026-08-15). It used to be switched off where it stood — which on
|
|
// the striker's phone is a whole court and a latency away from the call, and read as the game
|
|
// loop stopping. It is `scored` (nothing it does can move the scoreboard again) and still LIVE.
|
|
assert.ok(ball.scored, 'the call is made: this flight can never score again');
|
|
assert.ok(ball.active, '...but the ball keeps rolling instead of freezing where the call caught it');
|
|
assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back');
|
|
const pts = score.pts.slice(), sn = G.serveNum;
|
|
for (let i = 0; i < 600 && ball.active; i++) update(DT);
|
|
assert.ok(!ball.active, 'and it comes to a natural halt by itself');
|
|
assert.deepEqual(score.pts, pts, 'the roll scored nothing on the way');
|
|
assert.equal(G.serveNum, sn, '...and faulted nothing either');
|
|
});
|
|
|
|
test('netplay: the host\'s court rides the room, so both phones play the same surface', () => {
|
|
const { court, selectCourt } = state;
|
|
selectCourt('grass');
|
|
freshNet('B'); // ...and the start message says otherwise
|
|
netMsg({ t: 'start', side: 'B', court: 'clay' });
|
|
assert.equal(state.court.id, 'clay', 'the guest is moved onto the host\'s court');
|
|
assert.equal(TUNE.bounceXZ, cfg.COURTS.find(c => c.id === 'clay').bounceXZ,
|
|
'...and onto its physics, not just its picture');
|
|
netMsg({ t: 'start', side: 'B' }); // an old build sends no court: keep what we have
|
|
assert.equal(state.court.id, 'clay', 'a start without a court changes nothing');
|
|
selectCourt('grass');
|
|
});
|
|
|
|
test('netplay: the scoreboards reconcile, so one lost verdict cannot lock the court', () => {
|
|
// THE BUG (Oliver, over cellular): "both players stand on the same side and nobody can serve."
|
|
// setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so two sims
|
|
// a point apart place both players on the same half and each waits for the other. The host's
|
|
// pretoss stream carries its scoreboard; the guest adopts it, mirrored.
|
|
freshNet('B'); // we are the guest: the host is the authority
|
|
score.pts = [2, 1]; score.server = 'P'; // ...and we have drifted a point away from it
|
|
const myX = P.x;
|
|
netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [2, 3] }); // their P serves, their 2 to our 3
|
|
assert.equal(score.server, 'O', 'the server is taken from the host, mirrored');
|
|
assert.deepEqual(score.pts, [3, 2], '...and so are the points');
|
|
assert.notEqual(P.x, myX, 'the point is re-armed, so both bodies stand where the new score puts them');
|
|
// an ordinary position frame carries no scoreboard and must never touch one
|
|
score.pts = [1, 1];
|
|
netMsg({ t: 'pos', x: 0, z: 1, m: 0 });
|
|
assert.deepEqual(score.pts, [1, 1], 'a plain pos frame is picture only');
|
|
// ...and a live rally is never reconciled underneath itself
|
|
G.phase = 'rally';
|
|
netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [3, 3] });
|
|
assert.deepEqual(score.pts, [1, 1], 'mid-rally the scoreboard is left alone');
|
|
// only the HOST publishes it: two authorities is the drift, not the cure
|
|
freshNet('B'); netTest.sent = []; G.phase = 'pretoss';
|
|
netTick(0.2);
|
|
assert.ok(netTest.sent.some(m => m.t === 'pos'), 'the guest still streams its position');
|
|
assert.ok(!netTest.sent.some(m => m.srv), '...but never a scoreboard');
|
|
freshNet('A'); netTest.sent = []; G.phase = 'pretoss';
|
|
netTick(0.2);
|
|
assert.ok(netTest.sent.some(m => m.t === 'pos' && m.srv), 'the host publishes its scoreboard');
|
|
});
|
|
|
|
// ---- NETPLAY DEATH MATCH: the host's pick rides the room's start message, and the match
|
|
// machinery is driven by the same receiver-is-judge verdicts classic uses — routed through
|
|
// resolveInterlude instead of pointTo, so wounds, blood, the fall and the burst all come along.
|
|
function freshNetDeath(side) {
|
|
restartRun();
|
|
G.classic = true; // the waiting court, exactly as the menu builds it
|
|
assert.ok(startNetVs(), 'net vs takes a clean court');
|
|
netTest.sent = [];
|
|
netMsg({ t: 'start', side, death: true });
|
|
}
|
|
|
|
test('netplay death match: the host\'s pick rebuilds the court on pairing — friend, not roster body', () => {
|
|
freshNetDeath('B');
|
|
assert.ok(G.net && G.net.ready && G.net.death, 'the wire survives the mode swap');
|
|
assert.equal(G.mode, 'splatter');
|
|
assert.ok(G.match && G.interlude, 'the match machinery owns the court');
|
|
assert.ok(!zombies.some(z => z.variant === 'r'), 'NO roster body — the friend IS the opponent');
|
|
assert.equal(G.hideO, false, 'the living opponent is the picture');
|
|
assert.equal(G.card, null, 'and no SHAMBLER walk-on card');
|
|
assert.equal(score.server, 'O', 'the host serves first here too');
|
|
assert.equal(G.modal, null, 'no popup may eat the first remote serve');
|
|
});
|
|
|
|
test('netplay death match: their lost points read as BLOOD, and the game bursts the body', () => {
|
|
freshNetDeath('A');
|
|
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
|
|
assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp');
|
|
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
|
|
const blood = G.hp;
|
|
netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me
|
|
assert.equal(G.hp, blood - 1, 'their point draws my blood');
|
|
score.pts = [3, 0]; // game point against them: the flow at full ramp
|
|
const before = parts.length;
|
|
for (let i = 0; i < 30; i++) update(DT);
|
|
assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)');
|
|
netMsg({ t: 'pt', win: 'you' }); // the game-winning point
|
|
assert.equal(score.games[0], 1, 'game banked');
|
|
assert.deepEqual(score.pts, [0, 0], 'the blood ramp resets with the body');
|
|
assert.equal(G.hideO, true, 'the burst took the body');
|
|
for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT);
|
|
assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again');
|
|
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
|
|
});
|
|
|
|
test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => {
|
|
freshNetDeath('B');
|
|
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
|
|
const blood = G.hp;
|
|
for (let i = 0; i < 900 && ball.active; i++) update(DT);
|
|
assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge');
|
|
assert.equal(G.hp, blood - 1, 'their untouched winner draws my blood');
|
|
const pt = netTest.sent.find(m => m.t === 'pt');
|
|
assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone');
|
|
});
|
|
|
|
test('netplay death match: bleeding out is said on the wire, and the other phone wins on it', () => {
|
|
freshNetDeath('B');
|
|
G.hp = 1;
|
|
netMsg({ t: 'pt', win: 'me' }); // the point that empties me
|
|
assert.equal(G.mode, 'eaten', 'my run ends as every match death does');
|
|
assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud');
|
|
freshNetDeath('A'); // ...and the winner's phone, receiving it
|
|
score.games[0] = 3;
|
|
netMsg({ t: 'dead' });
|
|
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending');
|
|
assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body');
|
|
});
|
|
|
|
test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {
|
|
assert.ok(_startMatch(), 'solo match on a clean court');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
const rz = zombies.find(z => z.variant === 'r');
|
|
G.matchFall = { t: 0.3, from: 1, vx: 0, x0: rz.x, kill: true }; // landed, prone, pre-burst
|
|
const before = parts.length;
|
|
for (let i = 0; i < 20; i++) update(DT);
|
|
assert.ok(parts.length > before, 'the prone hold gushes (gushProne), where bleed() is suppressed');
|
|
});
|
|
|
|
// stats.js ships its own fixture-based check — the period filter, the mode split and the name
|
|
// normalisation that has to agree with server.py. It was exported and never called by anything.
|
|
test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); });
|
|
|
|
// ---- THE PLAYER'S COLOURS. The feature's one real claim is that the kit can move without dragging
|
|
// the skin with it, and the whole claim lives in tintPx. These are the colours MEASURED off
|
|
// p_idle1.png (the four biggest opaque populations), not invented ones — if the art is ever redrawn
|
|
// into a palette these buckets do not separate, this is the test that says so.
|
|
const { tintPx } = await import('./src/render.js');
|
|
const SHORTS = [22, 120, 186], SHORTS_DK = [0, 91, 159]; // kit blue, lit and in shadow
|
|
const SKINPX = [214, 149, 111], SHIRT = [214, 213, 200]; // forearm, and the shirt white
|
|
const dom = ([r, g, b]) => r > g && r > b ? 'r' : g > b ? 'g' : 'b';
|
|
|
|
test('kit and skin are separate buckets: recolouring one leaves the other alone', () => {
|
|
const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0];
|
|
assert.equal(asDrawn.h, undefined, 'SKINS[0] is the "as drawn" entry');
|
|
|
|
// the kit moves, in the light AND in the shadow — a bucket that only catches the lit ramp step
|
|
// leaves the folds behind in the old colour, which is the failure that would actually be visible
|
|
assert.equal(dom(tintPx(...SHORTS, red, asDrawn)), 'r', 'lit shorts turn red');
|
|
assert.equal(dom(tintPx(...SHORTS_DK, red, asDrawn)), 'r', 'shadowed shorts turn red too');
|
|
|
|
// ...and the skin does not, which is the question the whole design turns on
|
|
assert.equal(tintPx(...SKINPX, red, asDrawn), null, 'skin is untouched by a kit change');
|
|
assert.equal(tintPx(...SHIRT, red, asDrawn), null, 'the shirt white is in neither bucket');
|
|
});
|
|
|
|
test('skin recolours by lightness, and stays a skin tone rather than rotating to green', () => {
|
|
const asDrawn = cfg.KITS[0], deep = cfg.SKINS.find(s => s.id === 'deep');
|
|
const out = tintPx(...SKINPX, asDrawn, deep);
|
|
assert.equal(dom(out), 'r', 'a darker tone is still red-dominant, not hue-rotated off the band');
|
|
assert.ok(out[0] < SKINPX[0], 'DEEP is genuinely darker than the drawn tan');
|
|
assert.equal(tintPx(...SHORTS, asDrawn, deep), null, 'the kit is untouched by a skin change');
|
|
});
|
|
|
|
test('the shading survives the swap: the ramp keeps its order and its spread', () => {
|
|
const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0];
|
|
const lit = tintPx(...SHORTS, red, asDrawn), dk = tintPx(...SHORTS_DK, red, asDrawn);
|
|
const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
|
|
assert.ok(lum(lit) > lum(dk), 'the lit step stays lighter than the shadowed one');
|
|
// hue rotation keeps lightness, so the gap between the two ramp steps must survive within rounding
|
|
assert.ok(Math.abs((lum(lit) - lum(dk)) - (lum(SHORTS) - lum(SHORTS_DK))) < 12,
|
|
'the ramp keeps its spread — a flattened one would read as a painted-on patch');
|
|
});
|
|
|
|
// THE TWO NEIGHBOURS THE SKIN BUCKET MUST NOT EAT. Both of these shipped broken once — the shirt
|
|
// came out mud-spattered, and then the death frames' blood came out brown, which on this game is
|
|
// the worst bug in the file. Colours measured off p_run1/p_smash (the shirt) and p_die4 (the pool).
|
|
const SHIRT_WARM = [243, 236, 224], SHIRT_W2 = [240, 232, 224]; // warm HIGHLIGHTS, not neutral
|
|
const BLOOD = [150, 20, 25], POOL = [170, 15, 20], BLOOD_DK = [92, 10, 14];
|
|
|
|
test('the shirt stays white — its warm highlights are not skin', () => {
|
|
for (const sk of cfg.SKINS.slice(1)) {
|
|
assert.equal(tintPx(...SHIRT_WARM, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`);
|
|
assert.equal(tintPx(...SHIRT_W2, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`);
|
|
}
|
|
});
|
|
|
|
test('BLOOD STAYS BLOOD — no skin pick may turn the death frames to mud', () => {
|
|
for (const sk of cfg.SKINS.slice(1)) {
|
|
for (const [nm, c] of [['spray', BLOOD], ['pool', POOL], ['dried', BLOOD_DK]])
|
|
assert.equal(tintPx(...c, cfg.KITS[0], sk), null, `${sk.id} leaves the ${nm} red`);
|
|
}
|
|
// ...and it survives a kit change too, which is the other half of the same claim
|
|
for (const k of cfg.KITS.slice(1))
|
|
assert.equal(tintPx(...POOL, k, cfg.SKINS[0]), null, `${k.id} leaves the pool red`);
|
|
});
|
|
|
|
// A tour whose art has no kit sits the kit pick out — otherwise the only thing on her sheets inside
|
|
// the kit's hue arc is HER HAIR, and picking ACID dyed it green.
|
|
test("a tour played in whites carries no kit, so nothing of hers is dyed", () => {
|
|
const women = cfg.TOURS.find(t => t.id === 'women');
|
|
assert.equal(women.kit, false, 'the women\'s set is flagged as having no kit');
|
|
assert.equal(cfg.TOURS.find(t => t.id === 'men').kit, true, 'the men\'s set has one');
|
|
const HER_HAIR = [40, 44, 72]; // dark blue-black, hue ~220 — inside the kit arc
|
|
assert.ok(tintPx(...HER_HAIR, cfg.KITS[1], cfg.SKINS[0]), 'her hair IS in the kit arc (the trap)');
|
|
assert.equal(tintPx(...HER_HAIR, null, cfg.SKINS[0]), null, 'and a null kit is what spares it');
|
|
});
|
|
|
|
test('every kit and skin in the tables is a live, distinct pick', () => {
|
|
for (const table of [cfg.KITS, cfg.SKINS]) {
|
|
assert.equal(new Set(table.map(e => e.id)).size, table.length, 'ids are unique');
|
|
for (const e of table) {
|
|
assert.ok(/^#[0-9a-f]{6}$/i.test(e.sw), `${e.id} has a swatch colour for the menu`);
|
|
assert.ok(e.name, `${e.id} is labelled`);
|
|
if (e.h != null) assert.ok(e.h >= 0 && e.h < 360, `${e.id}'s hue is a degree`);
|
|
}
|
|
}
|
|
// and the "as drawn" head of each table must actually be a no-op, or the default player is tinted
|
|
assert.equal(tintPx(...SHORTS, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
|
|
assert.equal(tintPx(...SKINPX, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
|
|
});
|
|
|
|
console.log(`\n${passed} tests passed`);
|
|
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|