2370 lines
136 KiB
JavaScript
2370 lines
136 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() {} });
|
|
// haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL
|
|
// navigator and it is getter-only, so the stub has to be defined over it rather than assigned.
|
|
globalThis.hapticLog = [];
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } });
|
|
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, leaguePace, duelSkill, tinLadder, deliverTin, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = 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, explode, hurtPlayer } = 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, CONTACT, racketStage, matchWound } = cfg;
|
|
const statsMod = await import('./src/stats.js');
|
|
const audio = await import('./src/audio.js');
|
|
const { hapticTest } = await import('./src/haptics.js');
|
|
const prof = await import('./src/profile.js');
|
|
const { advancePoint } = await import('./src/gameplay.js');
|
|
const { read: readStats, check: statsCheck } = statsMod;
|
|
|
|
const DT = 1 / 60;
|
|
function freshSplatter() {
|
|
restartRun();
|
|
// THE LEAGUES: a won cup PROMOTES the profile (by design), which would leak league 2 into every
|
|
// test after the tournament walkthrough — each test opens in league 1 with no ceremony pending.
|
|
// XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from
|
|
// earlier tests would silently buff every later one.
|
|
prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo();
|
|
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); }
|
|
// the walk-on is a MODAL now (2026-08-28) and it arrives WITH the opponent — tap it away too,
|
|
// or every step after this one runs against a frozen world
|
|
G.modal = null; G.modalQ.length = 0;
|
|
};
|
|
|
|
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 WOUNDS PLAY: a wounded opponent covers less and whiffs more', () => {
|
|
startMatch();
|
|
const zb = oppOf();
|
|
const w = f => matchWound({ hp: zb.hpMax * f, hpMax: zb.hpMax });
|
|
assert.deepEqual(w(1), { reach: 1, flub: 1 }, 'an unhurt body plays exactly as it always did');
|
|
const arm = w(0.5), head = w(0.25); // one point in is still stage 0 — a quarter is 0.75
|
|
assert.ok(head.reach < arm.reach && arm.reach < 1, 'reach falls with every stage it loses');
|
|
assert.ok(head.flub > arm.flub && arm.flub > 1, '...and the whiff rate climbs with it');
|
|
assert.ok(head.reach > 0.85 && head.flub < 1.5,
|
|
'and it stays TEXTURE under the ladder, not a second difficulty curve: ' + JSON.stringify(head));
|
|
// ...and it is wired to the racket, not just to the table: a gap the whole body covers and the
|
|
// headless one cannot. ONE frame, so its own chase (~0.06 m) can never close the 0.19 m gap.
|
|
const swingsAt = (hp, d) => {
|
|
zb.hp = hp; zb.swingCool = 0; zb.swingT = -1;
|
|
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.pow = 0;
|
|
ball.x = zb.x + d; ball.z = zb.z; ball.y = 1.0;
|
|
ball.vx = 0; ball.vy = 0; ball.vz = -1; // incoming, or racketAI ignores it entirely
|
|
update(DT);
|
|
return zb.swingCool > 0; // a whiff arms the cooldown too — this is REACH
|
|
};
|
|
const gap = TUNE.aiReach * (1 + head.reach) / 2; // between the two reaches
|
|
assert.ok(swingsAt(zb.hpMax, gap), 'the whole body reaches ' + gap.toFixed(2) + ' m');
|
|
assert.ok(!swingsAt(1, gap), '...and the headless one does not');
|
|
});
|
|
|
|
test('the FLICK LESSON arms on a run of aimless returns and dies on an aimed one', () => {
|
|
assert.equal(flatCount(0, { ax: 0, az: 0 }), 1, 'a flat return is evidence');
|
|
assert.equal(flatCount(3, { ax: 0, az: -1 }), 4, '...and so is a depth-only one: this teaches WIDTH');
|
|
assert.equal(flatCount(9, { ax: -1, az: 0 }), 0, 'one aimed return and the lesson is over');
|
|
assert.equal(flatCount(9, null), 10, 'no aim at all is the flattest shot there is');
|
|
assert.ok(TUNE.flickHintAfter > 2, 'hitting flat is legal — it takes a RUN of them to nag');
|
|
// the wiring: a real match return with nothing on it counts itself
|
|
startMatch();
|
|
const zb = oppOf();
|
|
G.interlude.introT = 0; // the umpire has finished announcing — the tap is a swing now
|
|
G.flatStreak = 0; G.phase = 'rally';
|
|
P.x = 0; P.z = 23; P.swingT = -1;
|
|
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
|
|
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
|
|
keyPressed('Space');
|
|
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
|
|
assert.equal(ball.lastHit, 'P', 'he got a racket on it');
|
|
assert.equal(G.flatStreak, 1, 'and the flat return was counted');
|
|
assert.ok(zombies.includes(zb), '(the body is untouched by any of this)');
|
|
});
|
|
|
|
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('SECOND SERVICE: a missed serve in the match faults instead of losing the point', () => {
|
|
// Oliver, 2026-08-22: "when serving results in an out there should be a second service... the
|
|
// umpire should announce 2nd service and the player must get another chance." The duel and the
|
|
// match had no service rule at all — the first serve was judged as an ordinary ground stroke, so
|
|
// a serve long or into the net was a point, and in the match a point costs blood.
|
|
startMatch();
|
|
for (let i = 0; i < 2.6 * 60; i++) update(DT); // the walk-on beat, then his serve
|
|
const zb = oppOf();
|
|
const pts = score.pts.slice(), hp = G.hp;
|
|
zb.x = 3.9; zb.z = 5.5; // it chased the fault out to the wing
|
|
const serveOut = () => { // a serve that lands past the far baseline
|
|
G.phase = 'rally'; G.wasServe = true;
|
|
ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0;
|
|
ball.x = 0.4; ball.z = 4; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -20; ball.g = TUNE.gravity;
|
|
for (let i = 0; i < 60 && ball.active; i++) update(DT);
|
|
};
|
|
serveOut();
|
|
assert.equal(G.serveNum, 2, 'the first miss buys a second serve');
|
|
assert.deepEqual(score.pts, pts, '...and costs no point');
|
|
assert.equal(G.hp, hp, '...and no blood');
|
|
assert.equal(G.matchFall, null, 'nobody falls over a fault');
|
|
assert.equal(zb.z, 1.0, 'the body is back on its receiving mark for the second serve');
|
|
assert.equal(zb.x, O.x, '...on the same mark the invisible tennis body serves against');
|
|
serveOut(); // the double fault
|
|
assert.equal(score.pts[1], pts[1] + 1, 'two misses is the point, theirs');
|
|
assert.equal(G.hp, hp - 1, '...and the match scores it in blood, like every other lost point');
|
|
assert.equal(G.serveNum, 1, 'the next point starts on a first serve');
|
|
});
|
|
|
|
test('SECOND SERVICE: a serve that lands IN is still played, fault or not', () => {
|
|
// The guard is on the OUT branch only — the mode's other rules (the early-decided winner, the
|
|
// second bounce) must not learn about serves.
|
|
startMatch();
|
|
for (let i = 0; i < 2.6 * 60; i++) update(DT);
|
|
const pts = score.pts.slice();
|
|
G.phase = 'rally'; G.wasServe = true;
|
|
ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0;
|
|
ball.x = 0.4; ball.z = 8; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -8; ball.g = TUNE.gravity;
|
|
for (let i = 0; i < 30 && ball.bounces < 1; i++) update(DT);
|
|
assert.equal(ball.bounces, 1, 'setup: it bounced');
|
|
assert.ok(inCourt(ball.x, ball.z), '...in court');
|
|
assert.equal(G.serveNum, 1, 'a legal serve is no fault');
|
|
assert.deepEqual(score.pts, pts, 'and no point either — it is a rally now');
|
|
});
|
|
|
|
test('a duel opens on a FIRST serve, whatever the last one ended on', () => {
|
|
// Same shape as the match's death loop, one mode over: nothing in startDuel reset G.serveNum, and
|
|
// now that a duel can fault, a fault in one duel would have armed second-serve in the next.
|
|
restartRun();
|
|
G.mode = 'splatter';
|
|
G.serveNum = 2;
|
|
startDuel();
|
|
assert.equal(G.serveNum, 1, 'the duel is served for the first time');
|
|
});
|
|
|
|
test('a receiver stands BEHIND his baseline, on both sides of the net', () => {
|
|
// Oliver: "when the opponent serves the player does not stand at the baseline — but that's how it
|
|
// should be." Both marks used to sit over a metre inside the court.
|
|
restartRun();
|
|
score.server = 'O'; setupPoint();
|
|
assert.ok(P.z > COURT.L, 'receiving, the player waits behind his own baseline: z=' + P.z);
|
|
assert.ok(O.z < -0.2, '...and the server is behind his: z=' + O.z);
|
|
score.server = 'P'; setupPoint();
|
|
assert.ok(O.z < 0, 'and mirrored, the far receiver is behind the far baseline: z=' + O.z);
|
|
assert.ok(P.z > COURT.L, '...with the server behind his own line');
|
|
});
|
|
|
|
|
|
test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => {
|
|
// Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the
|
|
// opponent sees the ball really fast — the player barely sees the speed increase." The ball IS
|
|
// the same ball on both phones (net.js copies the velocities verbatim); what differs is the
|
|
// camera, which compresses your own outgoing ball into the distance and expands theirs as it
|
|
// comes at you. So the striker gets the pace in three channels he cannot miss: the ghost streak's
|
|
// LENGTH (render), the crack's pitch, and this.
|
|
restartRun(); G.classic = true;
|
|
score.server = 'P'; setupPoint();
|
|
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
|
|
G.shake = null;
|
|
hitServe(P, 1, null, true); // a flush serve: ~49 m/s
|
|
assert.ok(G.shake && G.shake.mag > 0, 'a 49 m/s serve kicks: mag ' + (G.shake && G.shake.mag.toFixed(1)));
|
|
const hard = G.shake.mag;
|
|
// ...and a slow ball does not. Same call, no timing on it.
|
|
score.server = 'P'; setupPoint();
|
|
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
|
|
G.shake = null;
|
|
hitServe(P, 0.05, null, false); // a badly mistimed one: ~24 m/s
|
|
assert.ok(Math.hypot(ball.vx, ball.vz) < 30, 'setup: that one left slowly');
|
|
assert.equal(G.shake, null, 'a slow serve does not kick the camera');
|
|
assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')');
|
|
});
|
|
|
|
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 LEAGUES: every league's cup draws only from its own band, its final is the band's top
|
|
// rung and nothing else, and the five bands tile the whole ladder with no gap and no overlap
|
|
let expectLo = 1;
|
|
for (const l of cfg.LEAGUES) {
|
|
const [lo, hi] = l.band;
|
|
assert.equal(lo, expectLo, `${l.name} starts where the league below ended`);
|
|
expectLo = hi + 1;
|
|
const R = cfg.cupRoundsFor(l.id);
|
|
assert.deepEqual(R[R.length - 1].band, [hi], `${l.name}'s final is its top rung`);
|
|
assert.equal(R.length, hi - lo + 1, `${l.name} plays one round per rung`);
|
|
for (const r of R) for (const rung of r.band)
|
|
assert.ok(rung >= lo && rung <= hi, `${l.name} ${r.name} stays in its band (${rung})`);
|
|
for (const r of R.slice(0, -1))
|
|
assert.ok(!r.band.includes(hi), `${l.name}'s top rung is only ever the final`);
|
|
assert.ok(cfg.COURTS.some(c => c.id === l.court), `${l.name}'s court exists`);
|
|
}
|
|
assert.equal(expectLo, LADDER + 1, 'the five bands cover the ladder exactly');
|
|
});
|
|
|
|
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)');
|
|
// ...and "make it a little harder to hit the out" (2026-08-18) is a NUMBER, not a vibe: the
|
|
// worst-case rate was halved off 15%, and this is what stops flickReach drifting back up.
|
|
assert.ok(pOut(0) < 0.10, 'a mistimed flick lands nearer the middle than the line: ' + (pOut(0) * 100).toFixed(1) + '%');
|
|
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 flick's LENGTH is its width: the three-lane cliff is gone", () => {
|
|
// The gesture used to be read as three LANES off a 0.38 ratio, so two neighbouring swipes differed
|
|
// by the whole court — "sometimes applied very strong, sometimes barely does anything", with no
|
|
// way to tell which one you had asked for. Length is the strength now, as it always was on serve.
|
|
const swipe = (m, fx = 1) => ({ ax: 1, fx, fy: 0, mag: m });
|
|
assert.equal(swayOf({ ax: -1 }), -1, 'keys have no length — a held direction is the whole lane');
|
|
assert.equal(swayOf(swipe(TUNE.flickMin)), 0, 'a swipe at the flick threshold aims nothing yet');
|
|
assert.equal(swayOf(swipe(TUNE.flickFull)), 1, '...and a committed one takes the whole width');
|
|
assert.equal(swayOf(swipe(999)), 1, 'over-swiping cannot aim past the corner');
|
|
let prev = 0;
|
|
for (let m = TUNE.flickMin; m <= TUNE.flickFull; m += 2) {
|
|
const v = swayOf(swipe(m));
|
|
assert.ok(v >= prev - 1e-9 && v - prev < 0.05, `the sway climbs smoothly at ${m}px, no cliff`);
|
|
prev = v;
|
|
}
|
|
// ...and a diagonal is a diagonal, not a lane vote: under the old ratio this aimed dead centre
|
|
assert.ok(Math.abs(swayOf(swipe(TUNE.flickFull, 0.35)) - 0.35) < 1e-9, 'fx carries the aim');
|
|
assert.equal(flatCount(3, swipe(TUNE.flickMin + 4)), 4, 'a swipe too short to bend it counts as flat');
|
|
assert.equal(flatCount(3, swipe(TUNE.flickFull)), 0, '...and a real one ends the lesson');
|
|
});
|
|
|
|
test('a gentle flick cannot be played out — only a committed one can', () => {
|
|
// Oliver: "the number one reason i die is that i myself keep firing balls into the out." Every
|
|
// aimed shot took the FULL lane and then +-aimError on top, so whether it sailed was the dice's
|
|
// call at any width the player picked. Width follows the swipe now, so the risk band has an edge
|
|
// the player can stop short of — and going out becomes a thing you chose, not a thing rolled.
|
|
const line = COURT.W / 2;
|
|
const worst = s => s * TUNE.flickWidth * TUNE.flickReach + TUNE.aimError; // widest it can land
|
|
assert.ok(worst(0.5) < line, 'half a swipe is in at ANY timing');
|
|
assert.ok(worst(1) > line, '...and a full one can still sail: the risk is kept, and it is chosen');
|
|
// the threshold render colours the live aim ring with is that same arithmetic, not a magic number
|
|
const safe = (line - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach);
|
|
assert.ok(safe > 0.4 && safe < 1, 'the ring turns hot inside the swipe range, not at either end');
|
|
});
|
|
|
|
test('an aimed shot keeps its DIRECTION: scatter may push it wider, never back past centre', () => {
|
|
// Dial 1 (2026-08-20). At rushed quality the symmetric scatter outweighed the quality-shrunk
|
|
// aim, so a mistimed left flick landed "wherever" — usually centre, read as the ball following
|
|
// the opponent. The model mirrors doHit's lane path: outward rolls untouched (the chosen
|
|
// out-risk keeps its exact tuned shape), inward rolls keep only aimTrust of themselves.
|
|
const land = (s, q, u) => { // u in [-1,1]: the raw scatter roll
|
|
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
|
|
let e = u * TUNE.aimError * (1 - q);
|
|
if (s && e * Math.sign(s) < 0) e *= TUNE.aimTrust;
|
|
return s * TUNE.flickWidth * reach + e;
|
|
};
|
|
const line = COURT.W / 2;
|
|
for (let q = 0; q <= 1; q += 0.1) {
|
|
assert.ok(land(0.5, q, -1) > 0, 'a committed right swipe stays right at q=' + q.toFixed(1)
|
|
+ ': ' + land(0.5, q, -1).toFixed(2));
|
|
assert.ok(land(-0.5, q, 1) < 0, '...and a committed left swipe stays left, mirrored');
|
|
}
|
|
assert.ok(land(1, 0, 1) > line, 'the outward tail is untouched: a mistimed full swipe can still sail');
|
|
assert.ok(land(1, 1, 1) < line, 'and a perfectly timed one still never does');
|
|
// the knob relationship the direction guarantee rests on — retuning must keep it
|
|
assert.ok(TUNE.aimTrust * TUNE.aimError < 0.5 * TUNE.flickWidth * TUNE.flickReach,
|
|
'aimTrust*aimError must stay under a committed swipe\'s worst-case aim');
|
|
});
|
|
|
|
test('an aimed ball never lands on the WRONG SIDE of you', () => {
|
|
// The absolute lane's blind spot: a mistimed left flick's target sits only slightly left of
|
|
// CENTRE — which is RIGHT of a player standing in the left box, so the ball visibly flew right
|
|
// out of a left flick. (The fully relative lane that fixed this harder was feel-vetoed the same
|
|
// day: "way too difficult now, I am dying constantly of out".) Model mirrors doHit's lane path.
|
|
const land = (px, s, q, u) => {
|
|
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
|
|
let e = u * TUNE.aimError * (1 - q);
|
|
if (s && e * Math.sign(s) < 0) e *= TUNE.aimTrust;
|
|
let tx = s * TUNE.flickWidth * reach + e;
|
|
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, px));
|
|
return s < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
|
|
};
|
|
for (let q = 0; q <= 1; q += 0.25) for (const u of [-1, 0, 1]) for (const s of [-0.2, -0.6, -1]) {
|
|
assert.ok(land(-2, s, q, u) <= -2, 'a left flick from the left box never crosses the player: '
|
|
+ land(-2, s, q, u).toFixed(2) + ' at s=' + s + ' q=' + q + ' u=' + u);
|
|
assert.ok(land(0, s, q, u) <= 0, '...and from centre it never crosses centre');
|
|
}
|
|
assert.equal(land(-2, -1, 1, 0), -TUNE.flickWidth, 'a perfect corner flick is untouched by the guard');
|
|
assert.equal(land(2, -1, 1, 0), -TUNE.flickWidth, 'crosscourt from the right box keeps its full sweep');
|
|
// the guard can never conjure an out: the bound is capped inside the line
|
|
assert.ok(TUNE.flickWidth < COURT.W / 2, 'flickWidth stays inside the singles line');
|
|
assert.ok(Math.abs(Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, -6.5))) <= TUNE.flickWidth,
|
|
'a body parked in the alley clamps to the corner, not past the line');
|
|
});
|
|
|
|
test('the flick aims from the LIVE swipe: contact reads G.swipe, no release needed', () => {
|
|
// The swing fires the moment the drag crosses flickMin now (input.js), so at contact the finger
|
|
// is usually still down and no released flickAim exists yet. The aim has to come from the live
|
|
// vector — without this, every crossing-fired flick would land flat and the latency fix (a left
|
|
// flick at the service line released too late to aim anything) would have swung at nothing.
|
|
startMatch();
|
|
G.modal = null; G.modalQ.length = 0;
|
|
const arm = () => { // one rally ball, contact at the window's centre
|
|
G.interlude.introT = 0; G.phase = 'rally';
|
|
P.x = 0; P.z = 22.8; P.swingT = -1; G.placeMark = null;
|
|
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
|
|
ball.x = 0; ball.y = 0.7; ball.z = P.z - 4.15;
|
|
ball.vx = 0; ball.vy = 2.0; ball.vz = 14; ball.g = TUNE.gravity;
|
|
keyPressed('Space'); // what the flickMin crossing fires mid-gesture
|
|
for (let i = 0; i < 30 && ball.lastHit !== 'P'; i++) update(DT);
|
|
assert.equal(ball.lastHit, 'P', 'contact happened');
|
|
return G.placeMark;
|
|
};
|
|
G.swipe = { ax: -1, az: 0, fx: -1, fy: 0, mag: TUNE.flickFull }; // held LEFT, finger still down
|
|
const left = arm();
|
|
assert.ok(left && left.x < -2, 'the live swipe aimed it left: x=' + (left ? left.x.toFixed(2) : 'none'));
|
|
G.swipe = { ax: 1, az: 0, fx: 1, fy: 0, mag: TUNE.flickFull };
|
|
const right = arm();
|
|
assert.ok(right && right.x > 2, '...and mirrored right: x=' + (right ? right.x.toFixed(2) : 'none'));
|
|
G.swipe = null; // bare tap, no gesture at all
|
|
assert.equal(arm(), null, 'no swipe and no released flick: a flat return stamps no mark');
|
|
});
|
|
|
|
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', () => {
|
|
_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');
|
|
const CUP_ROUNDS = d.cup.rounds; // THE LEAGUES: the run carries its own rounds
|
|
const dealt = [];
|
|
for (let round = 0; round < CUP_ROUNDS.length; round++) {
|
|
const R = CUP_ROUNDS[round];
|
|
assert.ok(!dealt.includes(d.opp), R.name + ' never re-deals a fallen body');
|
|
dealt.push(d.opp);
|
|
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, 'every round felled a body, banked as a match run');
|
|
// THE LEAGUES: this cup was league one's, so winning it IS the promotion
|
|
assert.equal(prof.prof.lg, 2, 'the cup is the promotion gate');
|
|
const pr = prof.takePromo();
|
|
assert.ok(pr && pr.to === 2, 'the ceremony is parked for the menu to play');
|
|
});
|
|
|
|
test('THE CUP CLIMBS: rounds run easy to hard, deterministic, the final the top of the band', () => {
|
|
// the shuffle retired with the draw screen (Oliver: "warum sollten wir noch mischen?") — a bad
|
|
// roll paired the strongest challenger with the shortest round, inverting the tuned curve
|
|
for (const l of cfg.LEAGUES) {
|
|
const R = cfg.cupRoundsFor(l.id);
|
|
const order = R.map(r => (assert.equal(r.band.length, 1, 'one settled body per round'), r.band[0]));
|
|
for (let i = 1; i < order.length; i++)
|
|
assert.ok(order[i] > order[i - 1], `${l.name}: round ${i + 1} is the harder body`);
|
|
assert.equal(order[0], l.band[0], `${l.name} opens on its easiest`);
|
|
assert.equal(order[order.length - 1], l.band[1], `${l.name} ends on its final`);
|
|
}
|
|
// ...and the run reads that order off exactly
|
|
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
|
assert.equal(G.match.opp, 1, 'round one walks on the easiest body');
|
|
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.
|
|
_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('a served ball with a slide on it is no longer a free kill', () => {
|
|
// Oliver: "the moment you serve you can serve hard with a slight slide and almost all of them
|
|
// fail to get the ball" — the first ten opponents died to one gesture. Measured before the fix:
|
|
// a flush, hard, swayed serve was returned 0 times out of 24 at EVERY rung of the ladder, because
|
|
// the receiver waited on the tennis mark (1.3 m off the centre line, covering the T and conceding
|
|
// the whole corner) and then chased the ball's LIVE position, which during a serve is still up by
|
|
// the server's baseline. Mark, first step and read are all dialled in config; this asserts the
|
|
// EXPERIENCE, not the dials, exactly like the reachability soak above.
|
|
// A serve into the very corner of the box is deliberately still an ace at the bottom of the
|
|
// ladder — the trick that had to die is the one that needed no placement.
|
|
const serve = sx => {
|
|
freshSplatter();
|
|
startMatch();
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
ball.active = true; ball.collected = false; ball.bounces = 0; ball.scored = false;
|
|
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.6; ball.vx = ball.vy = ball.vz = 0;
|
|
G.wasServe = true;
|
|
hitServe(P, 1, { sx, hard: 1 }); // flush timing, full pace, `sx` of slide
|
|
for (let i = 0; i < 5 * 60; i++) {
|
|
update(DT);
|
|
if (ball.lastHit === 'O') return true; // he got a racket on it
|
|
if (!ball.active || G.phase !== 'rally') break;
|
|
}
|
|
return false;
|
|
};
|
|
// n = 40, NOT 10, and this is the arithmetic to copy for any other rate gate here. The real return
|
|
// rate is ~0.88 (measured, 200 trials a seed across eight seeds: 0.85..0.92), so a 10-sample gate
|
|
// asking for 6 fails whenever the tail lands — p ~= 0.004, which is one seed in 230 and duly showed
|
|
// up as SEED=80. At 40 the same 60% bar sits five standard deviations below the true rate: the gate
|
|
// still fails instantly if the receiver regresses toward the 0-in-24 this test was born from, and
|
|
// it costs nothing (a returned serve breaks the frame loop early).
|
|
let got = 0;
|
|
const n = 40;
|
|
for (let i = 0; i < n; i++) if (serve(-0.5)) got++;
|
|
assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`);
|
|
});
|
|
|
|
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. MEDIUM used to be pinned dial-for-dial to the
|
|
// shipped TUNE values; that pin is gone (Oliver, 2026-08-15: "medium is too easy") — the middle
|
|
// chip is now its own tuning, above what "no difficulty" played like, and HARD stepped back.
|
|
// (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');
|
|
// EASY is a step below the shipped no-difficulty CPU, MEDIUM a step above it — that ordering is
|
|
// the point of the retune, so it stays pinned even though the exact numbers are free to move.
|
|
assert.ok(e.flub > TUNE.classicFlub && m.flub < TUNE.classicFlub, 'EASY misses more than shipped, MEDIUM less');
|
|
assert.ok(e.reaction > TUNE.aiReaction && m.reaction < TUNE.aiReaction, 'MEDIUM reads faster than shipped');
|
|
assert.ok(e.reach < TUNE.aiReach && m.reach > TUNE.aiReach, 'MEDIUM out-reaches shipped');
|
|
});
|
|
|
|
// ---- 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: THE BURST IS THE DEATH — a won game is only a game', () => {
|
|
// Oliver, 2026-08-22: "each player has 8 blood points but the player explodes for the winner
|
|
// already at 0:40 — the winner sees the zombie explode but the player on the other side is not
|
|
// dead yet. this needs to be aligned." Two clocks: a game is four points, a life is eight.
|
|
freshNetDeath('A');
|
|
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
|
|
assert.equal(score.pts[0], 1, 'the point banks');
|
|
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');
|
|
// THE FLOW READS THEIR BLOOD, off the position stream — not our points-this-game
|
|
const onHim = () => parts.filter(q => Math.abs(q.x - O.x) < 1 && Math.abs(q.z - O.z) < 1).length;
|
|
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: G.hpMax }); // (their z 2 mirrors to O.z 21.77... )
|
|
O.x = 0; O.z = 2; // ...so park him by hand: this is bleedO, not the lerp
|
|
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
|
|
assert.equal(onHim(), 0, 'a friend at full blood does not bleed, whatever our score says');
|
|
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: 1 });
|
|
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
|
|
assert.ok(onHim() > 0, '...and one a point from death bleeds hard');
|
|
score.pts = [3, 0];
|
|
netMsg({ t: 'pt', win: 'you' }); // the game-winning point
|
|
assert.equal(score.games[0], 1, 'game banked');
|
|
assert.equal(G.hideO, false, 'A GAME IS NOT A DEATH: the friend is still standing');
|
|
assert.ok(!G.done, '...and the match is not over');
|
|
for (let i = 0; i < 60 * 6; i++) update(DT);
|
|
assert.equal(G.hideO, false, 'still standing a beat later — no burst, no walk-on');
|
|
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
|
|
assert.ok(G.phase === 'pretoss' || G.phase === 'over', 'and the court is playable again');
|
|
});
|
|
|
|
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;
|
|
const before = parts.length;
|
|
netMsg({ t: 'dead' });
|
|
assert.equal(G.hideO, true, 'THE BURST IS THE DEATH: their blood ran out, so the body comes apart');
|
|
assert.ok(parts.length > before, '...and the gore is real');
|
|
assert.ok(!G.done, 'the summary waits — a frozen explosion behind a trophy screen is the bug');
|
|
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
|
|
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending, after the burst');
|
|
assert.equal(G.match.felled, 3, 'felled = the games won on the way');
|
|
});
|
|
|
|
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 WHITE KIT IS WASHED, NOT DYED. Rotating a near-neutral does nothing, and the only saturated
|
|
// blue on her sheets is HER HAIR — same hue as his shorts, so a dye turned it green. Colours
|
|
// measured off p_idle1_f.
|
|
const HER_SKIRT = [208, 216, 224], HER_BRIGHT = [240, 248, 248]; // cool-cast whites
|
|
const HER_HAIR = [40, 44, 72]; // dark blue-black, hue ~220
|
|
test('a white kit takes a wash: her whites colour, her hair and skin do not', () => {
|
|
assert.equal(cfg.TOURS.find(t => t.id === 'women').kit, 'wash');
|
|
assert.equal(cfg.TOURS.find(t => t.id === 'men').kit, 'dye');
|
|
const red = cfg.KITS.find(k => k.id === 'red');
|
|
|
|
for (const c of [HER_SKIRT, HER_BRIGHT]) {
|
|
const out = tintPx(...c, red, cfg.SKINS[0], 'wash');
|
|
assert.ok(out, 'her whites take the colour');
|
|
assert.equal(dom(out), 'r', 'CLARET reads as rose on her');
|
|
// a WASH, not a dye: it must stay pale, or she is wearing a block of poster paint
|
|
assert.ok(Math.min(...out) > 150, `${c} stays a pastel, not a solid (${out})`);
|
|
}
|
|
// the trap this whole mode exists to avoid, pinned from both sides
|
|
assert.ok(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'dye'), 'her hair IS in the dye arc — the trap');
|
|
assert.equal(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'wash'), null, 'a wash cannot reach it');
|
|
assert.equal(tintPx(...SKINPX, red, cfg.SKINS[0], 'wash'), null, 'nor her skin');
|
|
});
|
|
|
|
test('the wash leaves the shading: her folds stay in order', () => {
|
|
const red = cfg.KITS.find(k => k.id === 'red');
|
|
const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
|
|
const lit = tintPx(...HER_BRIGHT, red, cfg.SKINS[0], 'wash');
|
|
const shade = tintPx(...HER_SKIRT, red, cfg.SKINS[0], 'wash');
|
|
assert.ok(lum(lit) > lum(shade), 'the lit fold stays lighter than the shaded one');
|
|
});
|
|
|
|
// NETPLAY: the friend across the net wears their own pick, on the friend's OWN sheets — n_*, the
|
|
// player's kit drawn from the front (assets/_frontroll.py). The claim this pins is that he needs no
|
|
// arc of his own any more: every colour below is measured off n_idle1.png / n_idle1_f.png, and they
|
|
// fall into the SAME two buckets the player's back-view sheets do.
|
|
//
|
|
// The old netplay art was the roster opponent's red, on a third arc (`TINT.oKit`, 345->15). Both
|
|
// halves of why that had to go are pinned here: on his sheet the only thing in that arc was the
|
|
// SHIRT, so his pick moved a different garment than yours; on hers the arc also held her skin.
|
|
test('the friend across the net rides the PLAYER arcs: his shorts dye, her whites wash, skin holds', () => {
|
|
const HIS_SHORTS = [24, 112, 176], HIS_SHIRT = [224, 216, 208], HIS_SKIN = [232, 136, 104];
|
|
const HER_KIT = [240, 240, 248], HER_SKIN = [240, 168, 136], HIS_SHOE = [176, 176, 184];
|
|
const green = cfg.KITS.find(k => k.id === 'green');
|
|
const out = tintPx(...HIS_SHORTS, green, cfg.SKINS[0], 'dye', cfg.TINT.kit);
|
|
assert.ok(out && dom(out) === 'g', 'his blue shorts take the pick — the same garment yours moves');
|
|
assert.equal(tintPx(...HIS_SHIRT, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null,
|
|
'his white shirt stays white on a dye tour');
|
|
assert.equal(tintPx(...HIS_SHOE, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null,
|
|
'and his shoe grey is under the kit floor, blue-ish hue or not');
|
|
assert.ok(dom(tintPx(...HER_KIT, green, cfg.SKINS[0], 'wash')) === 'g',
|
|
'her whole white kit takes the wash — top and skirt together, which is the fix');
|
|
for (const [nm, c] of [['his', HIS_SKIN], ['hers', HER_SKIN]])
|
|
assert.equal(tintPx(...c, green, cfg.SKINS[0], nm === 'his' ? 'dye' : 'wash', cfg.TINT.kit), null,
|
|
`${nm} skin is left alone — the old red arc is what used to claim it`);
|
|
});
|
|
|
|
// The look crosses the wire ONCE, on pairing — neither phone can change its pick mid-match, because
|
|
// the setup screen is only reachable between runs.
|
|
test('netplay: your look goes over the wire on pairing, and theirs comes back', () => {
|
|
state.selectKit('green'); state.selectSkin('deep');
|
|
freshNet('A');
|
|
const look = netTest.sent.find(m => m.t === 'look');
|
|
assert.ok(look, 'pairing announces this phone\'s look');
|
|
assert.equal(look.k, 'green'); assert.equal(look.s, 'deep');
|
|
|
|
assert.equal(look.u, state.tour.id, "...and WHICH BODY it plays in — the tour is part of the look");
|
|
|
|
netMsg({ t: 'look', k: 'red', s: 'pale', u: 'women' });
|
|
assert.equal(G.net.look.kit.id, 'red', 'their kit is what the opponent is drawn in');
|
|
assert.equal(G.net.look.skin.id, 'pale');
|
|
assert.equal(G.net.look.tour.id, 'women', 'and their tour, whatever THIS phone picked');
|
|
// an older build, or a colour this one has never heard of, must not blank the opponent out
|
|
netMsg({ t: 'look', k: 'chartreuse', s: undefined, u: 'mixed doubles' });
|
|
assert.equal(G.net.look.kit.id, cfg.KITS[0].id, 'an unknown colour falls back to as-drawn');
|
|
assert.equal(G.net.look.skin.id, cfg.SKINS[0].id);
|
|
assert.equal(G.net.look.tour.id, state.tour.id, 'and an unknown tour falls back to OUR tour');
|
|
state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found
|
|
});
|
|
|
|
// THE NAME RIDES THE SAME MESSAGE — the lobby's one typed field, on the other phone's scoreboard.
|
|
// It is a stranger's string landing in an 8-character slot, so it is normalised at the receiving
|
|
// end (the same tidyName the world board and server.py's clean() agree on), never as sent.
|
|
test('netplay: the friend names themselves, and the name lands tidy in the board\'s slot', () => {
|
|
freshNet('A');
|
|
const look = netTest.sent.find(m => m.t === 'look');
|
|
assert.equal(look.n, statsMod.savedName(), 'this phone announces the name it remembers');
|
|
netMsg({ t: 'look', k: 'red', s: 'pale', n: ' <b>bob</b>ranathan ' });
|
|
assert.equal(G.net.look.name, 'BBOBBRAN', 'upper, stripped and cut to the eight-character slot');
|
|
netMsg({ t: 'look', k: 'red', s: 'pale' }); // an older build sends no name at all
|
|
assert.equal(G.net.look.name, 'FRIEND', '...and an unnamed opponent is FRIEND, never CPU');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
test('haptics: a blast that kills a dozen bodies is felt twice, not thirteen times', () => {
|
|
// The restraint rule is the whole feature: a hand that buzzes on every body in a bomb radius stops
|
|
// reading any of them. The gap lives in haptics.js so no call site has to know about it, and this
|
|
// is what proves it — the funnel (killZombie) is deliberately called once per kill.
|
|
const buzz = globalThis.hapticLog; buzz.length = 0; // the web fallback; no Capacitor bridge here
|
|
for (const k in hapticTest.at) delete hapticTest.at[k]; // a fresh hand
|
|
for (let i = 0; i < 12; i++) spawnZombie(0.3 * i - 1.8, 12, 'a');
|
|
explode(0, 12);
|
|
assert.equal(zombies.length, 0, 'the blast cleared its radius (or this proves nothing)');
|
|
assert.equal(buzz.length, 2, 'one heavy for the blast, one medium for the kills — not one per body');
|
|
});
|
|
|
|
test('the stand reads the SIZE of the moment, and a small cheer inside a big one cannot demote it', () => {
|
|
// crowd.pop is what decides whether the wave rolls and how many cameras go off (render.js), and
|
|
// every one of its inputs is a vol somebody passed to cheer(). The trap it is written against:
|
|
// a won rally landing in the middle of a match-point ovation must not drop the stand back to a
|
|
// polite clap, because cheer() is called from a dozen places and they overlap constantly.
|
|
const { crowd } = state;
|
|
crowd.cheer = crowd.pop = crowd.burst = 0;
|
|
cheer(0.8, 0.25); // a rally
|
|
assert.equal(crowd.pop, 0.25);
|
|
assert.ok(crowd.burst >= 1, 'even a small moment gets a shutter or two');
|
|
cheer(4, 1); // ...and then the match
|
|
assert.equal(crowd.pop, 1, 'the bigger moment owns the stand');
|
|
const queued = crowd.burst;
|
|
cheer(0.8, 0.25); // a rally INSIDE the ovation
|
|
assert.equal(crowd.pop, 1, 'a small cheer inside a live one may not demote it');
|
|
assert.ok(crowd.burst > queued, 'it still adds shutters, at the big moment\'s rate');
|
|
crowd.cheer = 0; // the ovation ends
|
|
cheer(0.8, 0.25);
|
|
assert.equal(crowd.pop, 0.25, 'once the stand has sat down, the next moment sets its own size');
|
|
crowd.cheer = crowd.pop = crowd.burst = 0;
|
|
});
|
|
|
|
// ---- 2026-08-28: the walk-on modal, the training court, and THE READ ---------------------------
|
|
|
|
test('the walk-on is a MODAL the player taps away, and it holds the world', () => {
|
|
// Oliver: "die Intros bitte als richtiges Modal, das man wegtappen muss — you can actually read
|
|
// it without time pressure, and tap it away fast when you know it." The timed 4 s card is gone.
|
|
G.seen.match = true; // the rules popup is not the subject here
|
|
assert.ok(_startMatch());
|
|
assert.ok(G.modal, 'the opponent announces as a modal, not a band card');
|
|
assert.equal(G.modal.title, cfg.matchFoe(1).name, 'it names the body');
|
|
assert.equal(G.modal.icon, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head');
|
|
const z0 = oppOf().z, intro0 = G.interlude.introT;
|
|
for (let i = 0; i < 60; i++) update(DT);
|
|
assert.equal(oppOf().z, z0, 'the frozen court holds while it is read');
|
|
assert.equal(G.interlude.introT, intro0, '...and the intro beat holds with it, however long that takes');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
endMatch();
|
|
});
|
|
|
|
test('THE TRAINING COURT: the rung is parked, nothing banks, and bleeding out is a modal', () => {
|
|
assert.ok(_startMatch(false, null, 5), 'training rides the match machinery');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
const d = G.match;
|
|
assert.ok(d.train, 'flagged as the training court');
|
|
assert.equal(d.opp, 5, 'the picked rung is the one that walks on');
|
|
const runGames0 = G.runGames;
|
|
G.interlude.introT = 0;
|
|
G.hp = 3; // a scuffed pool, to prove the reset
|
|
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
|
|
passMopBeat(); // the kill fall, the mop, the SAME body walking on
|
|
assert.equal(G.match.opp, 5, 'a felled body comes back as itself — the ladder never advances');
|
|
assert.equal(G.runGames, runGames0, 'no ladder metric moves on the training court');
|
|
assert.equal(G.hp, G.hpMax, 'every rep starts whole — the reset, without the trainer ceremony');
|
|
// ...and running out of blood is a popup and a fresh pool, never the death screen
|
|
G.interlude.introT = 0;
|
|
G.hp = 1;
|
|
resolveInterlude('O');
|
|
assert.notEqual(G.mode, 'eaten', 'the training court has no death in it');
|
|
assert.ok(G.modal && G.modal.title === 'IT BLED YOU OUT', 'bleeding out announces itself');
|
|
assert.equal(G.hp, G.hpMax, '...and hands the pool back');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
endMatch();
|
|
});
|
|
|
|
test('THE READ: a habit is learned by repetition and a break wrong-foots the racket', () => {
|
|
// The twist for the post-COACH wall ("very fast, barely make mistakes"): repeat a lane and the
|
|
// opponent anticipates it, break an established habit and it is wrong-footed for that ball.
|
|
G.seen.match = true;
|
|
_startMatch(); G.modal = null; G.modalQ.length = 0;
|
|
const zb = oppOf(), d = G.match;
|
|
d.opp = cfg.LADDER; zb.foe = cfg.matchFoe(cfg.LADDER); // the champion's dials, live
|
|
G.interlude.introT = 0;
|
|
const hit = fx => { // one real player swing, aimed by a committed flick
|
|
G.phase = 'rally'; P.x = 0; P.z = 23; P.swingT = -1;
|
|
zb.x = 0; zb.z = 2; zb.swingCool = 999; // it must not return the rig's ball
|
|
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
|
|
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
|
|
G.swipe = { ax: Math.sign(fx), fx, fy: 0, mag: TUNE.flickFull };
|
|
keyPressed('Space');
|
|
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
|
|
G.swipe = null;
|
|
assert.equal(ball.lastHit, 'P', 'the rig landed a swing');
|
|
ball.active = false; // the rally is not the subject
|
|
};
|
|
for (let i = 0; i < TUNE.readHabit; i++) hit(-1);
|
|
assert.equal(d.laneRun, TUNE.readHabit, 'a run of the same lane goes on the books');
|
|
assert.ok(!d.surprised && !d.expected, 'the habit itself surprises nobody');
|
|
hit(-1); // one more of the same: the anticipated ball
|
|
assert.ok(d.expected && !d.surprised, 'the repeated ball is anticipated — spam feeds the machine');
|
|
hit(1); // the break
|
|
assert.ok(d.surprised && !d.expected, 'the break wrong-foots him for this one flight');
|
|
assert.equal(d.laneRun, 1, '...and the new lane starts its own book');
|
|
// ...and the sprint dies with the chase: the rig just deactivated the ball, so one tick later
|
|
// the body carries no velocity — a stale vx kept the speed ghosts on a zombie that had already
|
|
// stopped ("teilweise bleiben die doppelten ghosts länger sichtbar")
|
|
update(DT);
|
|
assert.equal(zb.vx || 0, 0, 'no chase, no velocity — the ghost trail cannot outlive the sprint');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
endMatch();
|
|
// the dials must MATTER at the top and vanish at the bottom — pinned so they cannot drift:
|
|
const spd = n => (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (cfg.matchFoe(n).speed ?? 1);
|
|
assert.ok(spd(cfg.LADDER) * (1 - TUNE.surpSlow) < spd(14),
|
|
'a wrong-footed champion runs slower than the head coach at full stride');
|
|
const champFlub = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, cfg.LADDER - 1) * cfg.matchFoe(cfg.LADDER).flub;
|
|
assert.ok(champFlub < 0.02, '(the champion barely misses on his own — the wall being answered)');
|
|
// 0.15, not 0.25: the same-day retune ("die Gegner sind jetzt etwas zuu einfach") took a third
|
|
// off every surprise dial — the window must stay REAL, but it is an opening, not a free point
|
|
assert.ok(champFlub + TUNE.surpFlub > 0.15, 'the surprise opens a real whiff window in that racket');
|
|
assert.ok(TUNE.readAnticipate <= 1, 'anticipation never reads better than perfect');
|
|
assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game');
|
|
});
|
|
|
|
// ---- THE LEAGUES, phase 1: the profile clock -------------------------------
|
|
// The plan of record is PROJECT.md "THE LEAGUES". These pin the three promises phase 1 makes:
|
|
// the curve is fast-early/slow-late and capped, the award funnel pays every mode and mutes every
|
|
// no-stakes mode, and the run delta survives nothing it should not.
|
|
|
|
test('PROFILE: the level curve is monotonic, fast early, capped', () => {
|
|
let prev = -1;
|
|
for (let l = 1; l <= prof.LEVEL_CAP; l++) {
|
|
const x = prof.xpFor(l);
|
|
assert.ok(x > prev, 'xp thresholds strictly climb');
|
|
prev = x;
|
|
}
|
|
assert.ok(prof.xpFor(2) <= 120, 'level 2 lands inside the first minutes — the hook');
|
|
assert.ok(prof.xpFor(prof.LEVEL_CAP) > 50000, 'the cap is a long way off — levelling is not easy');
|
|
assert.equal(prof.levelAt(0), 1, 'everyone starts at level 1');
|
|
assert.equal(prof.levelAt(prof.xpFor(7)), 7, 'levelAt inverts xpFor on the threshold');
|
|
assert.equal(prof.progress(prof.xpFor(prof.LEVEL_CAP) + 999).frac, 1, 'the cap holds at a full bar');
|
|
// steps GROW: each level costs more than the one before (fast early IS slow late)
|
|
const step = l => prof.xpFor(l + 1) - prof.xpFor(l);
|
|
assert.ok(step(40) > step(10) && step(10) > step(2), 'the ladder steepens as it climbs');
|
|
});
|
|
|
|
test('PROFILE: award pays through the one point funnel, and mutes every no-stakes mode', () => {
|
|
const xp0 = prof.prof.xp;
|
|
advancePoint('P');
|
|
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'a rally point pays through advancePoint');
|
|
advancePoint('O');
|
|
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'the opponent\'s point pays you nothing');
|
|
// a won game pays the game on top of its final point (3 more points win it from 1-0)
|
|
score.pts = [3, 0];
|
|
advancePoint('P');
|
|
assert.equal(prof.prof.xp, xp0 + 2 * prof.XP.point + prof.XP.game, 'the game lands with its last point');
|
|
// every no-stakes mode is muted INSIDE award — a call site cannot forget the guard
|
|
for (const flag of ['drill', 'bench', 'testMode']) {
|
|
const before = prof.prof.xp;
|
|
G[flag] = true;
|
|
assert.equal(prof.award('kill'), 0, flag + ' pays nothing');
|
|
assert.equal(prof.prof.xp, before, flag + ' moved no xp');
|
|
G[flag] = false;
|
|
}
|
|
G.match = { train: true };
|
|
assert.equal(prof.award('kill'), 0, 'the training court pays nothing');
|
|
G.match = null;
|
|
assert.ok(prof.award('kill') > 0, '...and a real kill pays again');
|
|
});
|
|
|
|
test('PROFILE: the run delta survives the death screen and resets with the next run', () => {
|
|
prof.beginRunXp();
|
|
const from = prof.runDelta().from;
|
|
prof.award('kill'); prof.award('point');
|
|
const d = prof.runDelta();
|
|
assert.equal(d.xp, prof.XP.kill + prof.XP.point, 'the delta is the run\'s own earnings');
|
|
assert.equal(d.from, from, 'the starting level was recorded at the run start');
|
|
restartRun(); // the tap off the death screen
|
|
assert.equal(prof.runDelta().xp, 0, 'the next run opens with a clean delta');
|
|
assert.ok(prof.prof.xp > 0, '...while the banked xp survives, which is the whole point');
|
|
});
|
|
|
|
test('PROFILE: zombie names fit the board and its charset, and the profile round-trips', () => {
|
|
for (let k = 0; k < 60; k++) {
|
|
const n = prof.zombieName();
|
|
assert.ok(n.length >= 3 && n.length <= 12, 'a name fits the 12-char board limit: ' + n);
|
|
assert.equal(n, statsMod.tidyName(n), 'a rolled name is already board-legal: ' + n);
|
|
}
|
|
prof.prof.lg = 3; prof.save();
|
|
assert.ok(prof.prof.xp >= 0 && prof.prof.lg === 3, 'the profile holds what was written');
|
|
prof.prof.lg = 1; prof.save();
|
|
});
|
|
|
|
test('THE CLIMB: the arcade runs the whole earned ladder from the bottom and clamps at your top', () => {
|
|
prof.prof.lg = 2;
|
|
assert.ok(_startMatch(), 'the arcade starts');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
G.mode = 'splatter';
|
|
assert.equal(G.league, 2, 'the run wears the profile league');
|
|
const d = G.match;
|
|
assert.equal(d.opp, 1, 'the climb opens at the very bottom of the ladder');
|
|
for (let b = 0; b < 8; b++) {
|
|
G.interlude.introT = 0;
|
|
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
|
|
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
passMopBeat();
|
|
}
|
|
assert.equal(d.opp, 8, "the ladder clamps at your league's top — the cup is the only way up");
|
|
assert.equal(d.felled, 8, 'every felled body counts, clamp or no clamp');
|
|
// ...and the repeat walk-on ANSWERS the "what now?" it poses: fell the top body once more and
|
|
// read its intro modal instead of wiping it
|
|
G.interlude.introT = 0;
|
|
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
|
|
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
let saw = null;
|
|
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !saw; i++) {
|
|
update(DT);
|
|
if (G.modal && G.modal.body) saw = G.modal.body.join(' | ');
|
|
}
|
|
assert.ok(saw && saw.includes('THE CUP IS THE WAY UP'),
|
|
'the repeat walk-on carries the promotion lesson: ' + saw);
|
|
G.modal = null; G.modalQ.length = 0;
|
|
endMatch();
|
|
});
|
|
|
|
test('THE LEAGUES: the top league crowns a champion instead of promoting', () => {
|
|
prof.prof.lg = 5;
|
|
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
|
G.mode = 'splatter';
|
|
const d = G.match;
|
|
assert.equal(d.cup.rounds.length, 3, 'a three-rung league plays three rounds');
|
|
for (let round = 0; round < d.cup.rounds.length; round++) {
|
|
const R = d.cup.rounds[round];
|
|
G.interlude.introT = 0;
|
|
for (let g = 1; g <= R.need; g++)
|
|
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
|
|
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
if (round < d.cup.rounds.length - 1) passMopBeat();
|
|
}
|
|
assert.ok(G.done && G.done.cup, 'the trophy screen holds');
|
|
assert.equal(prof.prof.lg, 5, 'there is no league six');
|
|
assert.equal(prof.prof.ch, 1, 'the profile is crowned');
|
|
const pr = prof.takePromo();
|
|
assert.ok(pr && pr.champion, 'the ceremony parked is the champion one');
|
|
});
|
|
|
|
test("THE LEAGUES: the pace tier follows the OPPONENT's territory, and nothing else", () => {
|
|
G.match = { opp: 1 };
|
|
assert.equal(leaguePace(), 1, 'the bottom of the ladder IS the shipped game');
|
|
G.match.opp = 4;
|
|
assert.equal(leaguePace(), 1, '...flat inside a band: league one all plays league-one tempo');
|
|
G.match.opp = 18;
|
|
assert.ok(leaguePace() > 1.2, "the champion's territory is a visibly faster world");
|
|
const climbs = [1, 5, 9, 13, 16].map(o => { G.match.opp = o; return leaguePace(); });
|
|
for (let i = 1; i < climbs.length; i++)
|
|
assert.ok(climbs[i] > climbs[i - 1], 'each band border pulls the tempo up');
|
|
G.net = { on: true };
|
|
assert.equal(leaguePace(), 1, 'netplay is a human and exempt');
|
|
G.net = null; G.match = null;
|
|
assert.equal(leaguePace(), 1, 'outside the match modes the factor is 1 — gore keeps its own director');
|
|
});
|
|
|
|
test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shipped duel', () => {
|
|
G.match = null;
|
|
G.runGames = 0;
|
|
assert.equal(duelSkill(), 0, 'a fresh run duels the shipped opponent');
|
|
G.runGames = Math.round(TUNE.duelRampGames / 2);
|
|
const mid = duelSkill();
|
|
G.runGames = TUNE.duelRampGames + 10;
|
|
assert.ok(mid > 0 && mid < 1, 'the ramp is a ramp');
|
|
assert.equal(duelSkill(), 1, '...and it caps');
|
|
G.match = { opp: 5 };
|
|
assert.equal(duelSkill(), 0, 'the match opponent has its own ladder — the ramp is duel-only');
|
|
G.match = null;
|
|
// the dial relationships the ramp promises: a full-skill duel still misses and still misreads
|
|
assert.ok(TUNE.racketFlub * (1 - TUNE.duelFlubCut) > 0.05, 'a maxed duel still whiffs sometimes');
|
|
assert.ok(TUNE.duelReadMax < 1, 'a maxed duel can still be wrong-footed');
|
|
assert.ok(TUNE.racketQMax + TUNE.duelQBonus < 1, 'its returns never reach perfect quality');
|
|
});
|
|
|
|
test('THE WEAPON LICENCE: the league licenses, the run paces — tins and barrels agree, league 5 pays double', () => {
|
|
G.runGames = 0;
|
|
prof.prof.lg = 5;
|
|
assert.ok(!tinLadder().includes('laser'), 'even the top league waits for the run to reach lasers');
|
|
G.runGames = 12;
|
|
prof.prof.lg = 1;
|
|
assert.ok(!tinLadder().includes('laser') && !tinLadder().includes('rocket'),
|
|
'league one NEVER sees a laser, however deep the run — that was the barrel complaint');
|
|
prof.prof.lg = 3;
|
|
assert.ok(tinLadder().includes('laser') && !tinLadder().includes('rocket'),
|
|
'league three licenses the laser, not the rocket');
|
|
prof.prof.lg = 4;
|
|
assert.ok(tinLadder().includes('rocket'), 'league four licenses the rocket');
|
|
G.runGames = 0;
|
|
// the double tin: an empty rack, one delivery, twice the stock at league five
|
|
const stockOf = id => { const i = G.rack.findIndex(r => r.id === id); return i < 0 ? 0 : G.rack[i].n; };
|
|
G.rack.length = 0; G.temp = null; G.deliveries = 0;
|
|
prof.prof.lg = 5;
|
|
deliverTin();
|
|
const first = G.rack[0];
|
|
assert.equal(first.n, cfg.WEAPONS[first.id].tin * 2, 'a league-five duel pays a double tin');
|
|
G.rack.length = 0; G.temp = null; G.deliveries = 0;
|
|
prof.prof.lg = 1;
|
|
deliverTin();
|
|
assert.equal(G.rack[0].n, cfg.WEAPONS[G.rack[0].id].tin, 'league one pays the shipped tin');
|
|
});
|
|
|
|
test('THE LEVEL IS YOU: the defensive buffs scale with level and level one is the shipped game', () => {
|
|
const xp0 = prof.prof.xp;
|
|
prof.prof.xp = 0;
|
|
const b1 = prof.bonus();
|
|
assert.deepEqual(b1, { melee: 1, parry: 1, grace: 1, run: 1, window: 1 }, 'level one changes nothing');
|
|
prof.prof.xp = prof.xpFor(prof.LEVEL_CAP);
|
|
const bMax = prof.bonus();
|
|
assert.ok(bMax.melee < 1 && bMax.melee >= 0.7, 'the swish floor drops, boundedly');
|
|
assert.ok(bMax.parry > 1 && bMax.parry <= 1.3, 'the parry reaches further, boundedly');
|
|
assert.ok(bMax.grace > 1 && bMax.grace <= 1.4, 'the grace stretches, boundedly');
|
|
assert.ok(bMax.run > 1 && bMax.run <= 1.15, 'the legs quicken, boundedly');
|
|
assert.ok(bMax.window > 1 && bMax.window <= 1.2, 'the window widens, boundedly');
|
|
// FRONT-LOADED: level 20 must already carry more than half the cap's gift (Oliver plays there)
|
|
prof.prof.xp = prof.xpFor(20);
|
|
assert.ok((prof.bonus().run - 1) / (bMax.run - 1) > 0.5, 'the curve pays on the way up');
|
|
prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); // back to the cap for the grace-landing check below
|
|
// ...and the grace actually lands on the player: hurtPlayer writes the scaled window
|
|
G.mode = 'splatter'; G.hurtT = 0; G.dodgeI = 0; G.hp = 4;
|
|
hurtPlayer(1);
|
|
assert.ok(G.hurtT > TUNE.hurtGrace * 1.3, 'a maxed profile\'s grace window is visibly longer');
|
|
prof.prof.xp = xp0;
|
|
});
|
|
|
|
test('THE LEVEL IS YOU, in the league: legs and window grow with xp — baseline, classic and netplay untouched', () => {
|
|
const legLevel = _legLevel, winLevel = _winLevel, assistK = _assistK;
|
|
prof.prof.xp = 0;
|
|
G.match = { opp: 5 };
|
|
const legs0 = assistK();
|
|
assert.equal(legLevel(), 1, 'level one IS the shipped game');
|
|
assert.equal(winLevel(), 1, '...window too');
|
|
prof.prof.xp = prof.xpFor(20);
|
|
assert.ok(legLevel() > 1.05 && assistK() > legs0, 'level 20 legs are visibly quicker in the league');
|
|
assert.ok(winLevel() > 1.05, '...and the contact window visibly wider');
|
|
G.net = { on: true };
|
|
assert.equal(legLevel(), 1, 'netplay is a fair human match — no level legs');
|
|
assert.equal(winLevel(), 1, '...and no level window');
|
|
G.net = null; G.match = null; G.classic = true;
|
|
assert.equal(legLevel(), 1, 'classic keeps its own difficulty picker');
|
|
G.classic = false;
|
|
prof.prof.xp = 0;
|
|
});
|
|
|
|
console.log(`\n${passed} tests passed`);
|
|
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|