The tournament is the canonical path, so the game finally says so. The splash's primary button names the next cup fixture and enters the draw; the poster wall moved one tap down and deals three named rooms (the season, the training, out of competition) with the cup at the top. Two profile fields remember every attempt — rounds ever reached per league, and the body that put you out — so a lost cup is a line on the board instead of a null event, and the training court offers that body first, marked. The promotion names what waits in the new league. Overlays consolidated in the same pass: three modal drawers became one box with a masthead, hairline-separated rows and a real footer button (any tap still dismisses), and the run-end summary wears the same box — headline, xp, mark, and the fixture with ENTER THE DRAW beside PLAY AGAIN. The cup's own exit is a ceremony now: OUT IN THE QUARTER-FINAL, your best standing beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2425 lines
139 KiB
JavaScript
2425 lines
139 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();
|
|
// THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly
|
|
// the way the promotion did — the training court reads `out`, and a stale one reorders its list
|
|
prof.prof.cups = {}; prof.prof.out = 0;
|
|
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');
|
|
});
|
|
|
|
// ---- THE SEASON: the tournament is the story, so the club has to remember the attempts --------
|
|
|
|
test('THE SEASON: the fixture names the league, its rounds and the body that opens it', () => {
|
|
for (const l of cfg.LEAGUES) {
|
|
const fx = cfg.cupFixture(l.id), R = cfg.cupRoundsFor(l.id);
|
|
assert.equal(fx.rung, R[0].band[0], l.name + ': the fixture opens on round one\'s rung');
|
|
assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body');
|
|
assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE',
|
|
l.name + ': the title carries the league');
|
|
assert.ok(fx.line.includes(R[0].name) && fx.line.startsWith(String(R.length)),
|
|
l.name + ': the line counts the rounds and names the first');
|
|
}
|
|
// the champion has nothing above him: his fixture is a defence, not a promotion
|
|
assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE');
|
|
// ...and a record reads in the rounds' own words
|
|
const R3 = cfg.cupRoundsFor(3);
|
|
assert.equal(cfg.cupRoundName(3, 1), R3[0].name, 'one round reached is round one');
|
|
assert.equal(cfg.cupRoundName(3, R3.length), 'THE FINAL', 'the last round is the final');
|
|
});
|
|
|
|
test('THE SEASON: a cup exit is recorded, forward only, and names who put you out', () => {
|
|
prof.prof.lg = 3;
|
|
_startMatch(true); // league 3's cup, round one
|
|
G.modal = null; G.modalQ.length = 0;
|
|
G.match.cup.round = 2; // deep: the semi-final
|
|
const rung = G.match.cup.rung;
|
|
killPlayer();
|
|
assert.equal(prof.cupBest(3), 3, 'the round you were in is the round you reached');
|
|
assert.equal(prof.prof.out, rung, 'the body holding the racket is the one that put you out');
|
|
assert.equal(prof.cupBest(1), 0, 'and it is that league\'s record, not the club\'s');
|
|
// a shallower attempt afterwards leaves the record standing — and still updates the eliminator
|
|
restartRun();
|
|
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
|
|
prof.prof.lg = 3;
|
|
_startMatch(true);
|
|
G.modal = null; G.modalQ.length = 0;
|
|
G.match.cup.round = 0;
|
|
const rung2 = G.match.cup.rung;
|
|
killPlayer();
|
|
assert.equal(prof.cupBest(3), 3, 'a worse run never erases a better one');
|
|
assert.equal(prof.prof.out, rung2, 'but the LAST body to end an attempt is always the offer');
|
|
// the training court is not an attempt and writes nothing
|
|
const out = prof.prof.out;
|
|
restartRun();
|
|
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
|
|
_startMatch(false, null, 2);
|
|
G.modal = null; G.modalQ.length = 0;
|
|
killPlayer();
|
|
assert.equal(prof.prof.out, out, 'a training rep is a rep, not a cup attempt');
|
|
});
|
|
|
|
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.foe.head, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head (the zoned foe layout)');
|
|
assert.ok(G.modal.foe.lvl >= 1 && G.modal.foe.mine >= 1, '...and carries the level pair');
|
|
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('point'), prof.XP.point / 4, 'the training court pays a QUARTER — reps count');
|
|
G.match = null;
|
|
assert.equal(prof.award('point'), prof.XP.point, '...and the real court pays full');
|
|
});
|
|
|
|
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
|