At match point of an act's cup final the beaten finalist does not fall. The ceremony lands (the loot, still a plain card), then the applause is cut, the camera closes in, and on the turn he bursts and the act's boss stands where he stood, growing into the frame with his name and "GAME 10". He runs out of the picture to the left while the stands empty onto the court, and game 1 of the chapter begins without a cut: the crowd that came down is its first wave. The interlude holds serving, spawns and the boss's AI through the scene. The prologue is one game of tennis against a living opponent with the old transformation exactly as written; when it hands over, act I's cup takes the court. Two new tests. v380, staging only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3814 lines
219 KiB
JavaScript
3814 lines
219 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 { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, 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, startStory, startStoryChapter } = await import('./src/beats.js');
|
|
const { mirror, netMsg, netTest, netTick, netAgain } = 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, firePellet } = 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.at = {}; prof.prof.out = 0;
|
|
prof.prof.story = prof.freshStory(); // THE STORY's place leaks between tests the same way
|
|
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('league 5 has no speed dip below league 4, including serve and wrong-footed chases', () => {
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf();
|
|
const measure = (n, serve, surprised) => {
|
|
G.match.opp = n; G.match.surprised = surprised; G.match.expected = false;
|
|
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = serve;
|
|
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0,
|
|
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
|
|
update(DT);
|
|
return Math.hypot(zb.vx, zb.vz);
|
|
};
|
|
for (const serve of [false, true]) for (const surprised of [false, true]) {
|
|
const fourth = cfg.leagueOf(4).band, fifth = cfg.leagueOf(5).band;
|
|
let floor = 0;
|
|
for (let n = fourth[0]; n <= fourth[1]; n++) floor = Math.max(floor, measure(n, serve, surprised));
|
|
// since the legs have a knee (gameplay.knee) the late ladder runs at ~6-7.5 m/s, not 7-15
|
|
assert.ok(floor > TUNE.legKnee * 0.8, 'the rig measures a real full-speed chase: ' + floor.toFixed(2));
|
|
for (let n = fifth[0]; n <= fifth[1]; n++) {
|
|
const speed = measure(n, serve, surprised);
|
|
assert.ok(speed >= floor, `${cfg.matchFoe(n).short}: ${speed} must clear ${floor} (serve ${serve}, surprised ${surprised})`);
|
|
}
|
|
}
|
|
endMatch();
|
|
});
|
|
|
|
test('league 5 sustains strong returns and hunts the open court; league 4 stays unchanged', () => {
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf();
|
|
for (let n = 1; n <= cfg.leagueOf(4).band[1]; n++) {
|
|
assert.equal(cfg.matchFoe(n).smart, undefined, 'no new aim bonus in earlier leagues');
|
|
assert.equal(cfg.matchFoe(n).qMax, undefined, 'no new quality bonus in earlier leagues');
|
|
assert.equal(cfg.matchFoe(n).qMin, undefined, 'no new quality floor in earlier leagues');
|
|
}
|
|
for (const [n, smart, floor, cap] of [[15, 0, 0.45, 0.55], [16, 0.5, 0.62, 0.76], [17, 0.7, 0.68, 0.83], [18, 0.85, 0.74, 0.90]]) {
|
|
G.match.opp = n; zb.foe = cfg.matchFoe(n);
|
|
let hits = 0, open = 0, maxQ = 0, sumQ = 0;
|
|
for (let i = 0; i < 1200; i++) {
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
G.match.surprised = G.match.expected = false;
|
|
P.x = i % 2 ? 3 : -3; P.z = 23; P.swingT = -1;
|
|
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
|
|
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, // a good ball: no put-away
|
|
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
|
update(DT);
|
|
if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent
|
|
hits++;
|
|
assert.ok(ball.pow >= floor && ball.pow <= cap, `${n}: quality ${ball.pow} within its band`);
|
|
sumQ += ball.pow;
|
|
maxQ = Math.max(maxQ, ball.pow);
|
|
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
|
|
const landingX = ball.x + ball.vx * t;
|
|
// At q >= .45 the corner's inward scatter stays beyond 1.35; centre scatter stays inside.
|
|
if (landingX * Math.sign(P.x) < -1.35) open++;
|
|
ball.active = false;
|
|
}
|
|
assert.ok(hits > 1100, `${n}: enough actual returns (${hits})`);
|
|
assert.ok(maxQ > cap - 0.002, `${n}: the live return reaches its cap (${maxQ})`);
|
|
assert.ok(Math.abs(sumQ / hits - (floor + cap) / 2) < 0.01, `${n}: normal returns sustain the stronger quality`);
|
|
const expected = 1 / 3 + 2 / 3 * smart;
|
|
assert.ok(Math.abs(open / hits - expected) < 0.05,
|
|
`${n}: open-court share ${open / hits} should be near ${expected}`);
|
|
}
|
|
endMatch();
|
|
});
|
|
|
|
test('phone winners: the Champion approaches and returns the recorded short, low balls', () => {
|
|
// Cup points 21/22 from Oliver's v351 phone log. Both were untouched second-bounce winners:
|
|
// one landed near the net, the other bounced below the AI's old 24 cm contact floor.
|
|
const cases = [
|
|
{ opponent: [1.626, 5.485], player: [-1.035, 14.522], hp: 3,
|
|
pos: [-0.535, 1.194, 14.222], velocity: [1.467, 1.063, -7.611], g: 9.614 },
|
|
{ opponent: [2.676, 5.485], player: [-2.749, 19.533], hp: 2,
|
|
pos: [-2.249, 0.404, 19.233], velocity: [0.597, 3.678, -14.681], g: 9.698 },
|
|
];
|
|
const flub = TUNE.matchFlub;
|
|
TUNE.matchFlub = 0; // isolate movement/contact; reliability and surprise rates have their own tests
|
|
try {
|
|
for (const hz of [30, 60, 120]) for (const c of cases) {
|
|
freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(29);
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 18;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: c.opponent[0], z: c.opponent[1], hp: c.hp, hpMax: 8,
|
|
foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 });
|
|
Object.assign(P, { x: c.player[0], z: c.player[1], swingT: -1 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.05,
|
|
x: c.pos[0], y: c.pos[1], z: c.pos[2], vx: c.velocity[0], vy: c.velocity[1], vz: c.velocity[2], g: c.g });
|
|
for (let i = 0; i < hz * 3 && ball.active && ball.lastHit === 'P' && G.phase === 'rally'; i++) {
|
|
update(1 / hz);
|
|
assert.ok(rz.z <= COURT.netZ - 1.2, 'the approach stays on its own side');
|
|
}
|
|
assert.equal(ball.lastHit, 'O', `${hz}Hz: the reachable phone winner is returned`);
|
|
assert.deepEqual(score.pts, [0, 0], 'the rally continues instead of gifting a point');
|
|
}
|
|
} finally { TUNE.matchFlub = flub; }
|
|
});
|
|
|
|
test('match low contact: scoop rising balls, reject falling balls and contact across the net', () => {
|
|
const flub = TUNE.matchFlub;
|
|
TUNE.matchFlub = 0;
|
|
try {
|
|
for (const [z, y, vy, returns] of [[5, 0.12, 1.5, true], [5, 0.12, -1, false],
|
|
[COURT.netZ + 0.1, 1, 0, false]]) {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 0, z: Math.min(z, COURT.netZ - 1.2), swingT: -1, swingCool: 0 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1,
|
|
x: 0, y, z, vx: 0, vy, vz: -1, g: TUNE.gravity });
|
|
update(DT);
|
|
assert.equal(ball.lastHit === 'O', returns, `contact at z=${z}, y=${y}, vy=${vy}`);
|
|
}
|
|
} finally { TUNE.matchFlub = flub; }
|
|
});
|
|
|
|
test('match recovery: finish the serve, recover during the outbound rally, hold on dead balls', () => {
|
|
startMatch(); G.interlude.introT = 0;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 3, z: -0.5, serveHold: 0.4 });
|
|
G.phase = 'rally';
|
|
Object.assign(ball, { active: true, lastHit: 'O', bounces: 0,
|
|
x: 0, y: 2, z: 14, vx: 0, vy: 0, vz: 1 });
|
|
update(DT);
|
|
assert.deepEqual([rz.x, rz.z], [3, -0.5], 'serve follow-through holds its mark');
|
|
rz.serveHold = 0;
|
|
update(DT);
|
|
// THE COURT HAS ITS LENGTH: the baseliner's spot is BEHIND the line now (rallyBack), not 2.5 m in
|
|
assert.ok(rz.x < 3 && rz.z < -0.5 && rz.z >= -TUNE.rallyBack, 'recovers towards centre, to its spot behind the baseline');
|
|
const recoverySpeed = Math.hypot(rz.vx, rz.vz);
|
|
ball.lastHit = 'P'; ball.vz = -1;
|
|
update(DT);
|
|
assert.ok(Math.abs(Math.hypot(rz.vx, rz.vz) - recoverySpeed * 2) < 1e-9,
|
|
'recovery uses half chase speed, leaving room for wrong-footing');
|
|
for (const dead of ['inactive', 'scored', 'pretoss']) {
|
|
ball.lastHit = 'O'; ball.vz = 1; ball.active = dead !== 'inactive'; ball.scored = dead === 'scored';
|
|
G.phase = dead === 'pretoss' ? 'pretoss' : 'rally';
|
|
const pos = [rz.x, rz.z];
|
|
update(DT);
|
|
assert.deepEqual([rz.x, rz.z], pos, `${dead}: no recovery outside a live flight`);
|
|
assert.deepEqual([rz.vx, rz.vz], [0, 0], 'no stale sprint trail');
|
|
}
|
|
});
|
|
|
|
test('the approaching Champion can still be passed into the open back corner', () => {
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 18;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 3, z: 10, foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0, pow: 0.8,
|
|
x: -3.5, y: 0.3, z: 1.5, vx: 0, vy: -2, vz: -4, g: TUNE.gravity });
|
|
for (let i = 0; i < 60 && score.pts[0] === 0; i++) update(DT);
|
|
assert.deepEqual(score.pts, [1, 0], 'a legal passing shot beats a Champion stranded at the net');
|
|
});
|
|
|
|
// ---- THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT") ----------------------
|
|
// Oliver: "Aufschlag, dann schnell vor ans Netz ... ein sehr schneller Ballwechsel" — every match had
|
|
// become a volley exchange at the T. Measured: the rally ball landed ON the service line, the
|
|
// opponent stood on the bounce and volleyed 90-100% of everything from opponent 6 up, and the
|
|
// auto-run chased the earliest contact, ~0.7 s per shot. These pin the shape that replaced it.
|
|
|
|
test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not volleyed at the T', () => {
|
|
// the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
// a BASELINER (THE LINE JUDGE): since the styles (step 2) a net rusher volleys on his way in by
|
|
// design, so the baseline shape is asserted against a body whose game is the baseline
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 11;
|
|
assert.equal(cfg.matchFoe(11).style, 'baseliner', 'the subject plays from the back');
|
|
oppOf().foe = cfg.matchFoe(11); // the BODY plays the style, not the rung number
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
const hits = [];
|
|
let prev = ball.lastHit, putAway = false;
|
|
for (let i = 0; i < 120 * 60; i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 11; // keep playing: the shape is the subject
|
|
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 (d < 2.9 && d > 2.2) keyPressed('Space');
|
|
}
|
|
const b0 = ball.bounces, kill = putAway;
|
|
update(DT);
|
|
if (ball.active && ball.lastHit !== prev && !G.wasServe) {
|
|
const ch = ball.lastHit === 'P' ? P : oppOf();
|
|
// the answer to a PUT-AWAY (step 4) is a defence, not the rally's shape: a man caught on his heels
|
|
// takes it where he can, in the air included
|
|
if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT, kill });
|
|
putAway = ball.heels > 0;
|
|
}
|
|
prev = ball.active ? ball.lastHit : null;
|
|
}
|
|
const back = hits.filter(h => h.back);
|
|
assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts');
|
|
// the autopilot mistimes into short balls and a body is MEANT to come in on those, so the share of
|
|
// net contacts follows its timing; the back-court volley share below is the guard against the old T
|
|
assert.ok(back.length / hits.length >= 0.5, `most of the rally is played from the back: ${back.length}/${hits.length}`);
|
|
const volleys = back.filter(h => h.volley && !h.kill).length;
|
|
assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`);
|
|
const gaps = [];
|
|
for (let k = 1; k < hits.length; k++) if (hits[k].back && hits[k - 1].back && hits[k].t - hits[k - 1].t < 3)
|
|
gaps.push(hits[k].t - hits[k - 1].t);
|
|
gaps.sort((a, b) => a - b);
|
|
assert.ok(gaps[gaps.length >> 1] >= 0.75, 'a baseline exchange leaves time to play: median '
|
|
+ gaps[gaps.length >> 1].toFixed(2) + ' s per shot');
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobody approaches a man at the net', () => {
|
|
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
|
// the opponent's answer to one ball from the player's baseline, landing `depth` m inside its line
|
|
const play = (depth, pace, from) => {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 0, z: -TUNE.rallyBack, swingT: -1, swingCool: 0, serveHold: 0 });
|
|
Object.assign(P, { x: 0, z: 24, swingT: -1 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
const v = solveShot(0, 1, 22.5, 0.5, depth, pace);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.5, rx: null, from,
|
|
x: 0, y: 1, z: 22.5, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
let bounced = null, at = null;
|
|
for (let i = 0; i < 4 * 60 && ball.lastHit === 'P' && ball.active; i++) { bounced = ball.bounces; update(DT); }
|
|
assert.equal(ball.lastHit, 'O', `depth ${depth}: the opponent returns it`);
|
|
at = rz.z;
|
|
for (let i = 0; i < 30; i++) update(DT); // its own ball is in the air: where does it go now?
|
|
return { bounced, at, then: rz.z, approach: ball.approach };
|
|
};
|
|
try {
|
|
const deep = play(2.4, 24, 12);
|
|
assert.equal(deep.bounced, 1, 'a deep ball is played after the bounce');
|
|
assert.ok(deep.at < 1, 'from the baseline, not the T: met at z ' + deep.at.toFixed(2));
|
|
assert.equal(deep.approach, null, 'and it is no approach');
|
|
assert.ok(deep.then < 1, 'he stays back: ' + deep.then.toFixed(2));
|
|
const short = play(6.4, 15, 12);
|
|
assert.equal(short.approach, 'O', 'a short ball taken is an approach');
|
|
assert.ok(short.at > 2, 'he ran in for it: met at z ' + short.at.toFixed(2));
|
|
assert.ok(short.then > short.at + 0.5, 'and follows it in: ' + short.at.toFixed(2) + ' -> ' + short.then.toFixed(2));
|
|
const pass = play(6.4, 15, 3);
|
|
assert.equal(pass.approach, null, 'against a man at the net the same ball is a pass, never an approach');
|
|
} finally { TUNE.matchFlub = flub; }
|
|
});
|
|
|
|
test('the auto-run does not SHAKE: no back-and-forth while it chases a ball', () => {
|
|
// Oliver, 2026-10-14: "wenn der Autoplay den Ball versucht zu bekommen, zittert der Player schnell hin und
|
|
// her". intercept re-planned every frame and its margins (a bounce peaking on a height floor, a point on
|
|
// the back boundary) flipped the target by metres as the 10 ms flight grid slid against the frame:
|
|
// 9-62 reversals inside 200 ms per five minutes. The chase now latches its plan. Ring-tapping play.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
let fast = 0, chases = 0;
|
|
for (const opp of [4, 12, 18]) {
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = opp;
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
let last = 0, lastRev = -99, armed = false;
|
|
for (let i = 0; i < 60 * 60; i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = opp;
|
|
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' && ball.vz > 0 && !armed && ringCue()?.gold) { armed = true; keyPressed('Space'); }
|
|
if (ball.lastHit !== 'O') armed = false;
|
|
const x0 = P.x, chasing = G.phase === 'rally' && ball.active && ball.lastHit === 'O';
|
|
update(DT);
|
|
if (!chasing) { last = 0; continue; }
|
|
chases++;
|
|
const dx = P.x - x0;
|
|
if (Math.abs(dx) > 1e-4) {
|
|
if (last && Math.sign(dx) !== Math.sign(last)) { if (i - lastRev < 12) fast++; lastRev = i; }
|
|
last = dx;
|
|
}
|
|
}
|
|
endMatch();
|
|
}
|
|
assert.ok(chases > 3000, 'a real sample of chasing: ' + chases + ' frames');
|
|
assert.ok(fast <= 2, `no shake: ${fast} direction reversals inside 200 ms`);
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('THE FOLLOW-THROUGH: a tap ~80 ms early on a baseline ball is met weakly, not whiffed', () => {
|
|
// Step 4 (b), 2026-10-14. An early tap starts the swing while the ball is in the air, the swing waits
|
|
// for the bounce, and the groundstroke window had run out by then: a whiff at ~70 ms early, while a
|
|
// tap 120 ms LATE still met it. swingTail lets the swing meet it at q 0.05 — a sitter, not a miss.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
const run = () => {
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
let armed = false, incoming = 0, met = 0;
|
|
for (let i = 0; i < 90 * 60; i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
|
|
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' && ball.vz > 0 && !armed) {
|
|
const c = ringCue();
|
|
if (c && c.eta - c.sweet <= 5 / 60 + 1e-6 && c.eta - c.sweet > 4 / 60) { armed = true; incoming++; keyPressed('Space'); }
|
|
}
|
|
const before = ball.lastHit;
|
|
update(DT);
|
|
if (armed && before === 'O' && ball.lastHit === 'P') { met++; armed = false; }
|
|
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
|
|
}
|
|
endMatch();
|
|
return { incoming, met };
|
|
};
|
|
const tail = TUNE.swingTail;
|
|
try {
|
|
const on = run();
|
|
assert.ok(on.incoming >= 20, 'a real sample: ' + on.incoming);
|
|
assert.ok(on.met / on.incoming >= 0.8, `an ~80 ms early tap is met: ${on.met}/${on.incoming}`);
|
|
TUNE.swingTail = 0;
|
|
const off = run();
|
|
assert.ok(off.met / off.incoming < 0.6, `...where the old edge whiffed it: ${off.met}/${off.incoming}`);
|
|
} finally { TUNE.autoAssist = assist; TUNE.swingTail = tail; }
|
|
});
|
|
|
|
test('THE SITTER IS PUNISHED: the put-away chance climbs the ladder, goes for the line, and finds him on his heels', () => {
|
|
// Step 4 (b): before it, the CPU had no way to win a point — a q 0.05 floater came back like a drive
|
|
// and every point it took was the player's own whiff, which no opponent dial touches.
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf();
|
|
const share = (n, pow) => {
|
|
G.match.opp = n; zb.foe = cfg.matchFoe(n);
|
|
let hits = 0, kills = 0, wide = 0, heels = 0;
|
|
for (let i = 0; i < 1200; i++) {
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
G.match.surprised = G.match.expected = false;
|
|
P.x = 0; P.z = 23; P.swingT = -1;
|
|
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
|
|
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow, rx: null, spin: 1, soft: false,
|
|
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
|
update(DT);
|
|
if (ball.lastHit !== 'O') continue;
|
|
hits++;
|
|
if (ball.heels > 0) {
|
|
kills++; heels++;
|
|
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
|
|
if (Math.abs(ball.x + ball.vx * t) > 2.9) wide++;
|
|
assert.ok(ball.pow >= TUNE.punishQ, 'a put-away is struck clean');
|
|
}
|
|
ball.active = false;
|
|
}
|
|
return { hits, kills: kills / hits, wide: kills ? wide / kills : 0 };
|
|
};
|
|
const champ = share(cfg.LADDER, 0.1), first = share(1, 0.1), good = share(cfg.LADDER, 0.9);
|
|
assert.ok(Math.abs(champ.kills - TUNE.punishMax) < 0.06, `the champion puts a sitter away ${champ.kills.toFixed(2)}`);
|
|
assert.ok(first.kills < champ.kills - 0.3 && first.kills > 0.1, `the first rung does it rarely ${first.kills.toFixed(2)}`);
|
|
assert.equal(good.kills, 0, 'a good ball is never put away');
|
|
assert.ok(champ.wide > 0.9, `the champion's put-away goes for the line ${champ.wide.toFixed(2)}`);
|
|
endMatch();
|
|
});
|
|
|
|
test('a low SLICE drive is not a dug-out drop: the scoop cap is for soft backspin balls only', () => {
|
|
// Step 2's slicer hits every rally ball at spin 0.8, and the drop's dig-out cap read `spin < 1`, so every
|
|
// low contact against him came back at lowVolleyQ — found when step 4 started punishing sitters.
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf(); G.match.opp = 6; zb.foe = cfg.matchFoe(6);
|
|
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
|
try {
|
|
for (const soft of [false, true]) {
|
|
let n = 0, capped = 0;
|
|
for (let i = 0; i < 300; i++) {
|
|
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
|
|
P.x = 0; P.z = 23; P.swingT = -1;
|
|
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
|
|
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, rx: null, spin: 0.8, soft,
|
|
x: 0, z: 2.4, y: 0.3, vx: 0, vy: 0.5, vz: -1 });
|
|
update(DT);
|
|
if (ball.lastHit !== 'O') continue;
|
|
n++; if (ball.pow <= TUNE.lowVolleyQ + 1e-9) capped++;
|
|
ball.active = false;
|
|
}
|
|
assert.ok(n > 200, 'returns: ' + n);
|
|
if (soft) assert.equal(capped, n, 'a dug-out drop is still a weak reply');
|
|
else assert.equal(capped, 0, 'a low slice drive is an ordinary ball');
|
|
}
|
|
} finally { TUNE.matchFlub = flub; endMatch(); }
|
|
});
|
|
|
|
test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no cliff past perfectMs)', () => {
|
|
// Step 4's measurement (2026-10-14): q was 1 inside perfectMs and 1 - off/half outside it, so a
|
|
// groundstroke (half-window ~80 ms) fell from 1 to 0.43 a millisecond past PERFECT, and a tap 30 ms
|
|
// before the ring's kiss came back at q ~0.6, ~45 ms at 0.32 (0.57 now). The rally modes now fall from 1 at the edge of
|
|
// PERFECT to the window's edge. The finger lands 3 frames EARLY of the kiss, every ball.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
let armed = false, incoming = 0, returned = 0, sumQ = 0;
|
|
for (let i = 0; i < 90 * 60; i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
|
|
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' && ball.vz > 0 && !armed) {
|
|
const c = ringCue();
|
|
if (c && c.eta - c.sweet <= 3 / 60 + 1e-6 && c.eta - c.sweet > 2 / 60) { armed = true; incoming++; keyPressed('Space'); }
|
|
}
|
|
const before = ball.lastHit;
|
|
update(DT);
|
|
if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; }
|
|
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
|
|
}
|
|
assert.ok(incoming >= 20, 'a real sample: ' + incoming);
|
|
assert.ok(returned / incoming >= 0.9, `an early-ish tap still returns: ${returned}/${incoming}`);
|
|
assert.ok(sumQ / returned >= 0.42, 'and still returns a real ball (0.33 on the old curve): mean q ' + (sumQ / returned).toFixed(2));
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => {
|
|
// Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen".
|
|
// A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the
|
|
// groundstroke window that took a tap made exactly on the gold ring from q 0.82 to 0.09, and at
|
|
// 150 ms a third of the balls were simply missed. keyPressed's `lead` starts the swing clock at
|
|
// touch-down; this is the ring-tapping player, with a real finger's 120 ms of tap on top.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
const LAG = 7; // frames: ~120 ms between the finger landing and it lifting
|
|
let fire = -1, armed = false, incoming = 0, returned = 0, sumQ = 0;
|
|
for (let i = 0; i < 90 * 60; i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
|
|
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' && ball.vz > 0 && !armed) {
|
|
if (ringCue()?.gold) { armed = true; incoming++; fire = i + LAG; } // the finger LANDS on gold and lifts LAG later
|
|
}
|
|
if (i === fire) keyPressed('Space', LAG / 60); // the lift: the touch's age rides along
|
|
const before = ball.lastHit;
|
|
update(DT);
|
|
if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; }
|
|
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
|
|
}
|
|
assert.ok(incoming >= 30, 'a real sample: ' + incoming);
|
|
assert.ok(returned / incoming >= 0.9, `a gold-ring tap returns the ball: ${returned}/${incoming}`);
|
|
assert.ok(sumQ / returned >= 0.6, 'and returns it cleanly: mean q ' + (sumQ / returned).toFixed(2));
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
// ---- THE SOFT BALL (step 3, 2026-10-14): the down-flick is soft, its length is how far ----------------
|
|
// One player shot, struck from the baseline with the swing clock put exactly where trySwing scores it:
|
|
// q = 1 at the window's centre, `late` seconds off it for a mistimed one. The opponent is parked out of
|
|
// play so the flight is the shot's own. Returns where it landed, how high it flew, its spin, and the
|
|
// gap between its first and second bounce.
|
|
function softShot(mag, late = 0, rzZ = -0.6) {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 3, z: rzZ, swingCool: 999, swingT: -1 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(P, { x: 0, z: 24, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT + late });
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false },
|
|
spin: 1, soft: false, x: 0.4, y: 1.0, z: 23.6, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity });
|
|
G.swipe = { ax: 0, az: 1, fx: 0, fy: 1, mag };
|
|
update(DT); G.swipe = null;
|
|
assert.equal(ball.lastHit, 'P', 'the swing connects');
|
|
const q = ball.pow, spin = ball.spin, land = predictLanding();
|
|
let apex = ball.y, first = null, atNetMan = null;
|
|
const netMan = COURT.netZ - TUNE.netStand;
|
|
for (let i = 0; i < 6 * 60 && ball.active && ball.bounces < 2; i++) {
|
|
update(DT); apex = Math.max(apex, ball.y);
|
|
if (atNetMan === null && ball.z <= netMan) atNetMan = ball.y;
|
|
if (ball.bounces === 1 && !first) first = ball.z;
|
|
}
|
|
return { q, spin, depth: land.z, apex, atNetMan, roll: first === null ? null : Math.abs(ball.z - first), rz };
|
|
}
|
|
const winLevelNow = () => _winLevel();
|
|
|
|
test('THE SOFT BALL: a short down-flick is a dying drop, a long one an attacking lob over the net man', () => {
|
|
const drop = softShot(TUNE.flickMin + 2), lob = softShot(TUNE.flickFull + 20);
|
|
// Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob" — a NATURAL short flick (a thumb
|
|
// travels 40-80 px) must already be the drop, not a floater on the way to a lob
|
|
const flick = softShot(TUNE.dropMax - 15);
|
|
assert.ok(Math.abs(flick.depth - drop.depth) < 0.3 && flick.spin === drop.spin, 'a short natural flick is the drop');
|
|
assert.ok(drop.q > 0.95 && lob.q > 0.95, 'both struck flush');
|
|
assert.ok(Math.abs(drop.depth - (COURT.netZ - TUNE.dropNet)) < 0.7, 'the drop lands just over the net: z ' + drop.depth.toFixed(2));
|
|
// (dropApex is the ask; from the baseline the net-clearance loop lofts it a little, which is right)
|
|
assert.ok(drop.apex < lob.apex - 1.5, `on a low arc: ${drop.apex.toFixed(2)} m against the lob's ${lob.apex.toFixed(2)}`);
|
|
assert.ok(drop.spin < 0.5 && drop.roll < 2.5, `and it DIES: spin ${drop.spin.toFixed(2)}, ${drop.roll.toFixed(2)} m between its bounces`);
|
|
assert.ok(Math.abs(lob.depth - TUNE.lobDeep) < 0.8, 'the lob lands deep: z ' + lob.depth.toFixed(2));
|
|
assert.ok(lob.apex > TUNE.lobApex - 0.6 && lob.spin === 1, 'high, and with no backspin: ' + lob.apex.toFixed(2) + ' m');
|
|
// ...over a man at the net: where he stands it must be out of his racket's reach
|
|
assert.ok(lob.atNetMan > CONTACT.maxY + 0.5, `over the net man's racket: ${lob.atNetMan.toFixed(2)} m at his depth`);
|
|
});
|
|
|
|
test('a mistimed soft ball drifts toward the sitter — the easy ball, never an out', () => {
|
|
const clean = softShot(TUNE.flickFull + 20), rushed = softShot(TUNE.flickFull + 20, 0.07);
|
|
assert.ok(rushed.q < 0.3, 'the rushed one really is mistimed: q ' + rushed.q.toFixed(2));
|
|
assert.ok(Math.abs(rushed.depth - TUNE.softSitter) < Math.abs(clean.depth - TUNE.softSitter),
|
|
`it falls short of the corner: z ${clean.depth.toFixed(2)} -> ${rushed.depth.toFixed(2)}`);
|
|
assert.ok(rushed.depth > 0 && rushed.depth < COURT.netZ, 'and stays in the court');
|
|
});
|
|
|
|
test('a soft ball is DISGUISED: the opponent stands reading it, but runs at once for a drive', () => {
|
|
const moved = (soft) => {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 0, z: -0.6, swingCool: 999, swingT: -1 });
|
|
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 0;
|
|
const v = solveShot(0, 1, 23, 2.5, soft ? 9.5 : 3, soft ? 12 : 25);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: soft ? 0.3 : 1, soft,
|
|
x: 0, y: 1, z: 23, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
for (let i = 0; i < Math.round((TUNE.softRead - 0.05) * 60); i++) update(DT);
|
|
return Math.hypot(rz.x, rz.z + 0.6);
|
|
};
|
|
assert.ok(moved(false) > 0.5, 'a drive: he is already on his way');
|
|
assert.ok(moved(true) < 0.05, 'a drop: he has not read it yet');
|
|
});
|
|
|
|
test('a LOBBED net man who runs it down only gets a defensive ball back', () => {
|
|
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
|
try {
|
|
for (const lob of [false, true]) {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = 18;
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 0, z: 3, swingCool: 0, swingT: -1, foe: cfg.matchFoe(18) });
|
|
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 1;
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 1, spin: 1, soft: lob,
|
|
rx: { net: false, short: false, lob }, x: 0, y: 1.2, z: 3.6, vx: 0, vy: -1, vz: -4, g: TUNE.gravity });
|
|
update(DT);
|
|
assert.equal(ball.lastHit, 'O', 'he gets to it');
|
|
if (lob) assert.ok(ball.pow <= TUNE.lowVolleyQ + 1e-9, 'run down from a lob: q ' + ball.pow.toFixed(2));
|
|
else assert.ok(ball.pow > TUNE.lowVolleyQ, 'the same ball unlobbed is a full reply: q ' + ball.pow.toFixed(2));
|
|
}
|
|
} finally { TUNE.matchFlub = flub; }
|
|
});
|
|
|
|
// ---- THE STYLES (step 2, 2026-10-14): how a body plays is a row of dials --------------------------
|
|
// 400 returns off one body, the player parked at `pz`: what share came back soft, and the spins.
|
|
function styleReturns(rung, pz, aiZ = 2) {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = rung;
|
|
const zb = oppOf(); zb.foe = cfg.matchFoe(rung);
|
|
const out = { n: 0, soft: 0, lob: 0, drop: 0, spins: new Set() };
|
|
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
|
try {
|
|
for (let i = 0; i < 400; i++) {
|
|
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
|
|
P.x = 0; P.z = pz; P.swingT = -1;
|
|
zb.x = 0; zb.z = aiZ; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
|
|
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false, pow: 1, // no sitter: no put-away
|
|
x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
|
update(DT);
|
|
if (ball.lastHit !== 'O') continue;
|
|
out.n++;
|
|
if (ball.soft) { out.soft++; if (ball.spin < 1) out.drop++; else out.lob++; }
|
|
else out.spins.add(Math.round(ball.spin * 100) / 100);
|
|
ball.active = false;
|
|
}
|
|
} finally { TUNE.matchFlub = flub; }
|
|
return out;
|
|
}
|
|
|
|
test('THE STYLES: every opponent has one, and the walk-on says which', () => {
|
|
for (let n = 1; n <= cfg.LADDER; n++) {
|
|
const f = cfg.matchFoe(n), st = cfg.STYLES[f.style];
|
|
assert.ok(st, `${f.short} plays a known style (${f.style})`);
|
|
assert.ok(st.tag.length <= 38, `${f.style}'s tag fits the modal's one line: ${st.tag}`);
|
|
}
|
|
startMatch();
|
|
const m = G.modal || G.modalQ.find(q => q.foe);
|
|
assert.ok(!m || !m.foe || m.foe.style === cfg.STYLES[cfg.matchFoe(1).style].tag, 'the walk-on carries the tag');
|
|
});
|
|
|
|
test('a PUSHER moonballs as a habit and lobs a man at the net; a BIG HITTER carries topspin', () => {
|
|
const pusher = cfg.STYLES[cfg.matchFoe(1).style];
|
|
assert.equal(cfg.matchFoe(1).style, 'pusher');
|
|
const back = styleReturns(1, 24);
|
|
assert.ok(Math.abs(back.soft / back.n - pusher.loft) < 0.08, `moonballs ${back.soft}/${back.n} vs loft ${pusher.loft}`);
|
|
assert.equal(back.drop, 0, 'a pusher does not drop');
|
|
const net = styleReturns(1, COURT.netZ + TUNE.netStand);
|
|
assert.ok(Math.abs(net.lob / net.n - pusher.lob) < 0.08, `lobs at the net man ${net.lob}/${net.n} vs ${pusher.lob}`);
|
|
const power = styleReturns(5, 24);
|
|
assert.equal(cfg.matchFoe(5).style, 'power');
|
|
assert.deepEqual([...power.spins], [cfg.STYLES.power.spin], 'every big-hitter rally ball kicks');
|
|
assert.equal(power.soft, 0, 'and none of them floats');
|
|
});
|
|
|
|
test('a TOUCH player drops you from inside when you hang back — and never from his own baseline', () => {
|
|
assert.equal(cfg.matchFoe(7).style, 'touch');
|
|
const inside = styleReturns(7, COURT.L + 0.5, 4), deep = styleReturns(7, COURT.L + 0.5, -0.5);
|
|
assert.ok(Math.abs(inside.drop / inside.n - cfg.STYLES.touch.drop) < 0.08, `drops from inside ${inside.drop}/${inside.n}`);
|
|
assert.equal(deep.drop, 0, 'from behind his own line a drop is no option');
|
|
});
|
|
|
|
test('a NET RUSHER serves and volleys: the serve is his approach and he sprints in behind it', () => {
|
|
assert.equal(cfg.matchFoe(15).style, 'rusher');
|
|
startMatch(); G.interlude.introT = 0; G.match.opp = 15;
|
|
const zb = oppOf(); zb.foe = cfg.matchFoe(15);
|
|
score.server = 'O'; setupPoint();
|
|
Object.assign(zb, { x: 1.3, z: -0.5, serveHold: 0 });
|
|
// the serve's flight plus the return's: ~1.1 s — what a real serve-and-volleyer has to close in
|
|
let served = -1, z0 = null, appr = null, zMax = -99, rushed = false, sprint = 0;
|
|
for (let i = 0; i < 4 * 60 && !(served >= 0 && i - served > 66); i++) {
|
|
G.modal = null; G.modalQ.length = 0;
|
|
const zPrev = zb.z;
|
|
update(DT);
|
|
if (served < 0 && ball.active && ball.lastHit === 'O' && G.phase === 'rally') { served = i; z0 = zb.z; appr = ball.approach; }
|
|
if (served >= 0) { zMax = Math.max(zMax, zb.z); rushed ||= !!zb.rushing; sprint = Math.max(sprint, (zb.z - zPrev) / DT); }
|
|
}
|
|
assert.ok(served >= 0, 'he served');
|
|
assert.equal(appr, 'O', 'the serve is an approach');
|
|
assert.ok(rushed && zMax > z0 + 1.2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`);
|
|
// ...at a SPRINT, not the half-pace walk an ordinary recovery uses
|
|
assert.ok(sprint > 0.8 * cfg.TUNE.racketSpeed * 1.25, 'he sprints in: ' + sprint.toFixed(2) + ' m/s');
|
|
});
|
|
|
|
test('LOBBED at the net, the player backpedals too, and only blocks it back', () => {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
const zb = oppOf(); zb.swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 });
|
|
const v = solveShot(0, 1, 2, 0, COURT.L - 2, 9);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true,
|
|
x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
update(DT);
|
|
assert.ok(ball.rx && ball.rx.lob && !ball.rx.net, 'the plan turns: it is a lob, he runs it down');
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
// ---- THE PLAYER'S WAY TO THE NET AND BACK (the double tap), AND THE SMASH (2026-10-14, Oliver: "Steuerung ans Netz gehen"
|
|
// ... "wenn man einen Lob vom Gegner als Volley annimmt, soll man derb schmettern") ---------------------
|
|
// One player shot with a given swipe from (x 0, z pz), then `secs` of flight with the opponent parked out
|
|
// of play. Returns the shot's flags and where the player's own run took him.
|
|
function ownShot(swipe, pz, secs = 1.2, taps = []) {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
const rz = oppOf();
|
|
Object.assign(rz, { x: 3, z: -0.6, swingCool: 999, swingT: -1 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(P, { x: 0, z: pz, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT });
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false },
|
|
spin: 1, soft: false, x: 0.4, y: 1.0, z: pz - 0.4, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity });
|
|
G.swipe = swipe;
|
|
update(DT); G.swipe = null;
|
|
assert.equal(ball.lastHit, 'P', 'the swing connects');
|
|
let swung = false;
|
|
for (let i = 0; i < secs * 60 && ball.active; i++) {
|
|
rz.swingCool = 999;
|
|
if (taps.includes(i)) { keyPressed('Space', 0.1); swung ||= P.swingT >= 0; }
|
|
update(DT);
|
|
}
|
|
return { approach: ball.approach, retreat: ball.retreat, fromNet: Math.abs(P.z - COURT.netZ), swung };
|
|
} finally { TUNE.autoAssist = assist; }
|
|
}
|
|
|
|
test('THE DOUBLE TAP is his legs: from the back it takes him to the net, a single tap does nothing', () => {
|
|
// Oliver, 2026-10-14: "das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp ausgelöst werden".
|
|
// While his own ball flies there is nothing to hit, so taps there are the move, never a swing.
|
|
const deep = { ax: 0, az: -1, fx: 0, fy: -1, mag: TUNE.flickFull + 20 };
|
|
const plain = ownShot(deep, 24), one = ownShot(deep, 24, 1.2, [6]), two = ownShot(deep, 24, 1.2, [6, 6 + 10]);
|
|
assert.equal(plain.approach, null, 'a long up-flick is only a shot now: no run');
|
|
assert.ok(plain.fromNet > TUNE.netFrom + 3, `...he stays back: ${plain.fromNet.toFixed(1)} m`);
|
|
assert.ok(!one.swung && one.approach === null && one.fromNet > TUNE.netFrom + 3, 'one tap while his ball flies: nothing at all');
|
|
assert.equal(two.approach, 'P', 'the double tap is the way in');
|
|
assert.ok(!two.swung, '...and neither tap swung');
|
|
assert.ok(two.fromNet < TUNE.netFrom, `...and he is at the net by the time the ball gets there: ${two.fromNet.toFixed(1)} m`);
|
|
const slow = ownShot(deep, 24, 1.2, [6, 6 + Math.ceil(TUNE.dblTap * 60) + 3]);
|
|
assert.equal(slow.approach, null, 'two taps further apart than dblTap are not a double tap');
|
|
});
|
|
|
|
test('THE DOUBLE TAP at the net is the way back; a long down-flick is only the lob', () => {
|
|
const down = { ax: 0, az: 1, fx: 0, fy: 1, mag: TUNE.flickFull + 20 };
|
|
const lob = ownShot(down, COURT.netZ + TUNE.netStand, 1.5);
|
|
assert.equal(lob.retreat, null, 'the lob alone does not take him back');
|
|
assert.ok(lob.fromNet < TUNE.netFrom, `...he holds the net: ${lob.fromNet.toFixed(1)} m`);
|
|
const back = ownShot(down, COURT.netZ + TUNE.netStand, 1.5, [6, 14]);
|
|
assert.equal(back.retreat, 'P', 'the double tap at the net is the way back');
|
|
assert.ok(back.fromNet > TUNE.netFrom, `...and he is back behind the net zone: ${back.fromNet.toFixed(1)} m`);
|
|
});
|
|
|
|
test('THE DOUBLE TAP is a STANCE: a volley from mid-court keeps him coming in, not walking back', () => {
|
|
// Oliver on v376: "wenn man doppeltippt, geht er minimal vor". His own ball's flight covers 4-9 m of the 10,
|
|
// his next ball was met from mid-court — outside the net zone — and the recovery after it took him straight
|
|
// back to the baseline. P.post holds the net until the next double tap, a lob he runs down, or the point.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
const after = post => {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const rz = oppOf(); Object.assign(rz, { x: 3, z: -0.6, swingCool: 999, swingT: -1 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(P, { x: 0, z: COURT.netZ + 9, swingT: -1 }); // his volley was struck 9 m out
|
|
P.post = post;
|
|
const v = solveShot(0, 1, P.z - 0.4, 1, 3, 18);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: 1, soft: false,
|
|
approach: null, retreat: null, x: 0, y: 1, z: P.z - 0.4, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
for (let i = 0; i < 60 && ball.active; i++) { rz.swingCool = 999; update(DT); }
|
|
return Math.abs(P.z - COURT.netZ);
|
|
};
|
|
try {
|
|
const committed = after('net'), loose = after(null);
|
|
assert.ok(committed < TUNE.netFrom, `committed to the net, he keeps coming in: ${committed.toFixed(1)} m`);
|
|
assert.ok(loose > 9, `without the stance the old recovery still walks him back: ${loose.toFixed(1)} m`);
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('an INCOMING ball keeps every tap a swing: the double tap never eats a shot', () => {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
oppOf().swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
const v = solveShot(0, 1, 2, 0.5, 21, 20);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false,
|
|
x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
P.swingT = -1;
|
|
keyPressed('Space', 0.1);
|
|
assert.ok(P.swingT >= 0, 'the first tap swings');
|
|
P.swingT = -1;
|
|
keyPressed('Space', 0.1);
|
|
assert.ok(P.swingT >= 0, 'and so does the second');
|
|
assert.equal(ball.approach, null, 'no run was called');
|
|
});
|
|
|
|
test('THE SMASH: a lob taken out of the air is struck hard to the open court; a deep lob still beats him', () => {
|
|
// the opponent's lob from its baseline, at the player standing at the net; a gold-ring tap, as a player would
|
|
const lobAt = (tz, speed) => {
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
const zb = oppOf(); Object.assign(zb, { x: 2, z: 0.5, swingCool: 999 });
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 });
|
|
const v = solveShot(0, 1, 2, 0, tz, speed);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true, pow: 0.8,
|
|
heels: 0, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
let tapped = false;
|
|
for (let i = 0; i < 4 * 60 && ball.lastHit === 'O' && ball.active; i++) {
|
|
zb.swingCool = 999;
|
|
if (!tapped && ringCue()?.gold) { tapped = true; keyPressed('Space'); }
|
|
update(DT);
|
|
}
|
|
return { hit: ball.lastHit === 'P', smash: ball.smash, q: ball.pow, pace: Math.hypot(ball.vx, ball.vz),
|
|
land: ball.lastHit === 'P' ? predictLanding() : null };
|
|
} finally { TUNE.autoAssist = assist; }
|
|
};
|
|
const short = lobAt(COURT.L - 6, 9), deep = lobAt(COURT.L - 1.5, 9);
|
|
assert.ok(short.hit && short.smash, 'a short lob is smashed');
|
|
assert.ok(short.q > 0.6 && short.pace > 30, `...hard: q ${short.q.toFixed(2)}, ${short.pace.toFixed(1)} m/s`);
|
|
assert.ok(Math.abs(short.land.x) > 2.9, `...into the open court: lands at x ${short.land.x.toFixed(1)}`);
|
|
assert.ok(!deep.smash, 'a deep lob is not smashed — he turns and runs it down');
|
|
if (deep.hit) assert.ok(deep.q <= TUNE.lowVolleyQ + 1e-9, 'and only blocks it back');
|
|
});
|
|
|
|
test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and why the point went', () => {
|
|
// Oliver, 2026-10-14: "bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht
|
|
// bekommen habe". It was gated on G.mode === 'tennis' and the match runs as 'splatter' — the Climb
|
|
// and the cup logged nothing at all.
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
startMatch(); G.interlude.introT = 0;
|
|
for (let i = 0; i < 2.2 * 60; i++) update(DT);
|
|
G.missLog.length = 0;
|
|
const zb = oppOf(); zb.swingCool = 999;
|
|
const incoming = () => {
|
|
G.phase = 'rally'; G.wasServe = false; G.swung = false; G.rallyNear = null;
|
|
const v = solveShot(0, 1, 2, 0.5, 21, 20);
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false,
|
|
x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
|
};
|
|
incoming();
|
|
keyPressed('Space', 0.1); // far too early: the ball has barely left
|
|
for (let i = 0; i < 30; i++) update(DT);
|
|
const miss = G.missLog.find(e => e.result === 'MISS');
|
|
assert.ok(miss && /tap too early/.test(miss.reason) && /tap 100ms/.test(miss.reason), 'the whiff is explained: ' + (miss && miss.reason));
|
|
for (let i = 0; i < 4 * 60 && G.phase === 'rally'; i++) update(DT);
|
|
const lost = G.missLog.find(e => e.result === 'LOST');
|
|
assert.ok(lost, 'the point it cost is logged: ' + JSON.stringify(G.missLog.map(e => e.result)));
|
|
// a clean hit carries its shot and its tap
|
|
for (let i = 0; i < 4 * 60 && G.phase !== 'pretoss'; i++) update(DT);
|
|
incoming(); G.missLog.length = 0;
|
|
let lift = -1;
|
|
for (let i = 0; i < 3 * 60 && ball.lastHit === 'O'; i++) {
|
|
if (lift < 0 && ringCue()?.gold) lift = i + 6; // the finger lands on gold...
|
|
if (i === lift) keyPressed('Space', 0.1); // ...and lifts 100 ms later
|
|
update(DT);
|
|
}
|
|
const hit = G.missLog.find(e => e.result === 'HIT');
|
|
assert.ok(hit && /^q=/.test(hit.reason) && /tap 100ms/.test(hit.reason), 'the hit says how it was struck: ' + (hit && hit.reason));
|
|
// ...and the opponent's half is there: what it hit back, and where it lands relative to you
|
|
for (let i = 0; i < 2 * 60 && ball.lastHit === 'P'; i++) { zb.swingCool = 0; update(DT); }
|
|
const them = G.missLog.find(e => e.result === 'THEM');
|
|
assert.ok(them && (/lands/.test(them.reason) || /whiffed/.test(them.reason)), 'the reply is logged: ' + (them && them.reason));
|
|
} finally { TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('a deep ball the player waits for BEHIND his baseline is still his to hit — not "passed you"', () => {
|
|
// Oliver's v366 log vs THE BRUTE: "LOST passed you" with no swing. The match called the point the
|
|
// moment its ball crossed 0.3 m behind the baseline — but since step 1 the player waits up to
|
|
// rallyBehind behind it for a deep, kicking ball, so the ball was taken away on its way to him.
|
|
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf(); zb.swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0;
|
|
Object.assign(P, { x: 0, z: COURT.L + 1.8, swingT: -1, tgt: null, vx: 0, vz: 0 });
|
|
TUNE.autoAssist = 0; TUNE.matchAssist = 0; // he holds his deep mark: the ball comes to him
|
|
const v = solveShot(0, 1, 1, 1.25, COURT.L - 2.2, 27, TUNE.gravity * 1.6); // deep, a little wide, heavy topspin
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1.15, soft: false,
|
|
x: 0, y: 1, z: 1, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity * 1.6 });
|
|
let crossed = false, lift = -1;
|
|
for (let i = 0; i < 3 * 60 && ball.active && ball.lastHit === 'O'; i++) {
|
|
if (lift < 0 && ringCue()?.gold) lift = i + 6;
|
|
if (i === lift) keyPressed('Space', 0.1);
|
|
update(DT);
|
|
if (ball.z > COURT.L + 0.3) crossed = true;
|
|
}
|
|
assert.ok(crossed, 'the ball really did cross the old line');
|
|
assert.equal(ball.lastHit, 'P', 'and he returned it: ' + JSON.stringify(G.missLog.map(e => e.result + ' ' + e.reason)));
|
|
// ...while a ball that truly gets past him is still called, and says whether he swung
|
|
TUNE.autoAssist = assist; TUNE.matchAssist = 1.15;
|
|
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
|
oppOf().swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0;
|
|
Object.assign(P, { x: 0, z: 23, swingT: -1 });
|
|
const w = solveShot(0, 1, 1, 3.9, COURT.L - 3, 28); // wide, past him
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false,
|
|
x: 0, y: 1, z: 1, vx: w.vx, vy: w.vy, vz: w.vz, g: TUNE.gravity });
|
|
TUNE.autoAssist = 0; TUNE.matchAssist = 0; // a statue: it must go by
|
|
for (let i = 0; i < 3 * 60 && G.phase === 'rally'; i++) update(DT);
|
|
const lost = G.missLog.find(e => e.result === 'LOST');
|
|
assert.ok(lost && /never swung, it came within/.test(lost.reason), 'the verdict says how close it came: ' + (lost && lost.reason));
|
|
} finally { TUNE.matchFlub = flub; TUNE.autoAssist = assist; TUNE.matchAssist = 1.15; }
|
|
});
|
|
|
|
test('a topspin ball kicking out wide is planned where the player may go, and returned', () => {
|
|
// Oliver's v369 log, 84.26 s: "ball line 2.72m wide — run, don't swing", runTo=8.73. Since the dip
|
|
// ends at the bounce, a ball landing just inside the line drifts on sideways long enough that the
|
|
// auto-run planned him far outside the frame he is held in (xLimit) — he waited at the edge.
|
|
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
|
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
|
try {
|
|
startMatch(); G.interlude.introT = 0;
|
|
oppOf().swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0;
|
|
Object.assign(P, { x: -1.2, z: 24.4, swingT: -1, tgt: null });
|
|
const g = TUNE.gravity * (1 + 0.68 * TUNE.spinFactor);
|
|
const v = solveShot(-4, 1, -0.6, 3.3, COURT.L - 3.67, 30, g); // hard cross-court: lands 3.3, kicks on out
|
|
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1.15, soft: false,
|
|
x: -4, y: 1, z: -0.6, vx: v.vx, vy: v.vy, vz: v.vz, g });
|
|
let lift = -1, widest = 0;
|
|
for (let i = 0; i < 3 * 60 && ball.active && ball.lastHit === 'O'; i++) {
|
|
if (lift < 0 && ringCue()?.gold) lift = i + 6;
|
|
if (i === lift) keyPressed('Space', 0.1);
|
|
update(DT);
|
|
if (P.tgt) widest = Math.max(widest, Math.abs(P.tgt.x));
|
|
}
|
|
// (headless the frame is wide enough that the bound is P_BOUNDS.x; on a phone it is the screen, ~4.3)
|
|
assert.ok(widest <= cfg.P_BOUNDS.x + 1e-6, 'the run stays where he may stand: widest target x ' + widest.toFixed(2));
|
|
assert.equal(ball.lastHit, 'P', 'and he gets it back: ' + JSON.stringify(G.missLog.map(e => e.result + ' ' + e.reason)));
|
|
} finally { TUNE.matchFlub = flub; TUNE.autoAssist = assist; }
|
|
});
|
|
|
|
test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => {
|
|
// the only out a rally ball may find is wide — depth must never put one past the baseline
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf();
|
|
const sample = n => {
|
|
G.match.opp = n; zb.foe = cfg.matchFoe(n);
|
|
const depths = [];
|
|
for (let i = 0; i < 400; i++) {
|
|
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
|
|
P.x = 0; P.z = 24; P.swingT = -1;
|
|
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
|
|
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1, rx: null,
|
|
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
|
update(DT);
|
|
if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests)
|
|
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
|
|
depths.push(COURT.L - (ball.z + ball.vz * t));
|
|
ball.active = false;
|
|
}
|
|
return depths;
|
|
};
|
|
const weak = sample(1), strong = sample(18);
|
|
// solveShot aims at ty 0.08 (the ball on the grass), so the true bounce is a few dm past the mark
|
|
for (const d of [...weak, ...strong]) assert.ok(d >= TUNE.rallyDeep - 0.5, 'never long, never near the line: ' + d.toFixed(2));
|
|
const mean = a => a.reduce((s, x) => s + x, 0) / a.length;
|
|
assert.ok(mean(weak) > mean(strong) + 0.4, `weaker timing sits up shorter: ${mean(weak).toFixed(2)} vs ${mean(strong).toFixed(2)} m inside`);
|
|
endMatch();
|
|
});
|
|
|
|
test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => {
|
|
startMatch(); G.interlude.introT = 0;
|
|
const zb = oppOf();
|
|
const measure = (n, mode) => {
|
|
G.match.opp = n; G.match.train = mode === 'train';
|
|
G.match.cup = mode === 'cup' ? { round: 0, rounds: [{ need: 1 }] } : null;
|
|
G.match.surprised = G.match.expected = false;
|
|
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0,
|
|
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
|
|
update(DT);
|
|
const chase = Math.hypot(zb.vx, zb.vz);
|
|
let hits = 0, pace = 0, paced = 0, width = 0;
|
|
const seed = _seed;
|
|
_seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls
|
|
for (let i = 0; i < 1200; i++) {
|
|
G.phase = 'rally'; G.wasServe = false;
|
|
P.x = 0; P.z = 23; P.swingT = -1;
|
|
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
|
|
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1,
|
|
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
|
update(DT);
|
|
if (ball.lastHit !== 'O') continue;
|
|
// a style's soft ball takes its pace from its arc on purpose, so the pace dial reads rally balls
|
|
hits++; if (!ball.soft) { pace += Math.hypot(ball.vx, ball.vz); paced++; }
|
|
const t = (ball.vy + Math.sqrt(ball.vy ** 2 + 2 * ball.g * ball.y)) / ball.g;
|
|
width = Math.max(width, Math.abs(ball.x + ball.vx * t));
|
|
ball.active = false;
|
|
}
|
|
_seed = seed;
|
|
return { chase, hits, pace: pace / paced, width };
|
|
};
|
|
let previous = 0;
|
|
for (let n = 1; n <= 8; n++) {
|
|
const cup = measure(n, 'cup'), train = measure(n, 'train'), climb = measure(n, 'climb');
|
|
const same = (a, b, why) => {
|
|
assert.equal(a.chase, b.chase, why + ': chase');
|
|
assert.ok(Math.abs(a.pace / b.pace - 1) < 0.005, why + ': return pace');
|
|
assert.ok(Math.abs(a.width - b.width) < 0.12, why + ': width ' + a.width + ' / ' + b.width);
|
|
assert.ok(Math.abs(a.hits - b.hits) < 40, why + ': whiff rate');
|
|
};
|
|
same(train, cup, `${n}: the training rep matches its cup opponent`);
|
|
assert.ok(climb.chase > previous, `${n}: the arcade's early chase curve keeps rising`);
|
|
previous = climb.chase;
|
|
if (n < 8) assert.ok(climb.chase > cup.chase, `${n}: less opening assistance in arcade`);
|
|
else same(climb, cup, 'opponent eight rejoins the original curve');
|
|
if (n === 1) {
|
|
assert.ok(climb.chase / cup.chase > 1.3, 'the opening opponent covers materially more ground');
|
|
assert.ok(climb.pace / cup.pace > 1.2, 'its actual returns arrive faster');
|
|
assert.ok(climb.width / cup.width > 1.4, 'its actual returns use more of the court');
|
|
assert.ok(climb.hits > cup.hits + 40, 'it gives away fewer returns');
|
|
assert.ok(climb.width < 2.5, 'opening returns still leave a reachable margin');
|
|
}
|
|
}
|
|
G.match.cup = null; G.match.train = false;
|
|
endMatch();
|
|
});
|
|
|
|
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');
|
|
// the text reduction moved the round COUNT off the button and into the modal's sub line
|
|
assert.ok(fx.line.includes(R[0].name) && fx.line.includes(fx.foe.name),
|
|
l.name + ': the line names the first round and the body in it');
|
|
assert.ok(fx.sub.includes(String(R.length)) && fx.sub.includes(String(l.id)),
|
|
l.name + ': the sub carries the league and how many rounds it runs');
|
|
}
|
|
// 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 BOARD: the league ranks, the level it was taken at breaks the tie', () => {
|
|
// the inscription is written at the promotion and never again — a league is taken once
|
|
prof.prof.lg = 2; prof.prof.xp = prof.xpFor(9);
|
|
prof.recordCupWin(1, prof.levelAt(prof.prof.xp));
|
|
assert.equal(prof.cupAt(1), 9, 'the level that won the cup is the one inscribed');
|
|
prof.recordCupWin(1, 3);
|
|
assert.equal(prof.cupAt(1), 9, '...and a later write never overwrites it');
|
|
assert.equal(prof.cupAt(2), 0, 'a league not yet taken carries no inscription');
|
|
// the board itself: league is the score, the level the tie-break, and it is the ONE board whose
|
|
// second column is better LOW (server.py's rank_key is the other half of this)
|
|
const B = statsMod.BOARDS[statsMod.MODE_CUP];
|
|
assert.equal(B.score({ k: 4, g: 22 }), 4, 'the league ranks');
|
|
assert.equal(B.sub({ k: 4, g: 22 }), 22, 'the level rides along');
|
|
assert.ok(B.subLow, 'and lower is better on it');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// THE FRIEND'S RACKET (Oliver, 2026-09-11). Every other opponent is simulated locally and cracks
|
|
// through gameplay's own strike line; the friend's swing runs on nobody's phone but theirs, so the
|
|
// only place the sound can come from is the shot message — and only AFTER the ball it is pitched
|
|
// off has been written.
|
|
test('netplay: the friend\'s strike makes a noise, pitched off the ball it just sent', () => {
|
|
freshNet('B');
|
|
audio.sfxTest.log = [];
|
|
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 });
|
|
assert.ok(audio.sfxTest.log.includes('tennis-opponent'), 'the far racket cracks on the wire event');
|
|
audio.sfxTest.log = null;
|
|
});
|
|
|
|
// ---- OPEN 13: the four missing sounds (2026-09-23). Synthesized, so the ear is the phone's job;
|
|
// what the suite pins is that each one fires at its EVENT, and that fire stays one clock, not a
|
|
// sound per body.
|
|
test('the machine squeaks, fire crackles and catches, the gun cracks, the boss stomps and growls', () => {
|
|
const heard = n => audio.sfxTest.log.includes(n);
|
|
freshSplatter();
|
|
audio.sfxTest.log = [];
|
|
spawnZombie(0, 8, 'm');
|
|
const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99;
|
|
// keep it ROLLING for the whole window: one squeak per grind tick is a 60% roll, and a single 3 m
|
|
// trip is only ~5 ticks — about 1 seed in 100 heard nothing (seed 31337 found it)
|
|
for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { if (m.rollTo === undefined) m.rollTo = m.x > 0 ? -3 : 3; update(DT); G.hp = 9; }
|
|
assert.ok(heard('squeak'), 'a rolling machine announces itself');
|
|
|
|
freshSplatter();
|
|
audio.sfxTest.log = [];
|
|
for (let k = 0; k < 8; k++) spawnZombie(-1.4 + k * 0.4, 6, 'a');
|
|
zombies[0].burnT = 3;
|
|
update(DT);
|
|
assert.ok(heard('crackle'), 'a burning body crackles');
|
|
assert.equal(audio.sfxTest.log.filter(n => n === 'whump').length, 1, 'an epidemic lights the row, and says so ONCE');
|
|
const lit = zombies.filter(z => z.burnT > 0).length;
|
|
audio.sfxTest.log = [];
|
|
for (let i = 0; i < 60; i++) update(DT);
|
|
const crackles = audio.sfxTest.log.filter(n => n === 'crackle').length;
|
|
assert.ok(lit >= 3 && crackles >= 3 && crackles <= 16, `one court clock, not one per body: ${crackles} crackles/s for ${lit} lit`);
|
|
|
|
audio.sfxTest.log = [];
|
|
for (let k = 0; k < 4; k++) firePellet();
|
|
assert.equal(audio.sfxTest.log.filter(n => n === 'gunshot').length, 2, 'the gun cracks every other round');
|
|
|
|
freshSplatter();
|
|
startBossFight(1);
|
|
audio.sfxTest.log = [];
|
|
for (let i = 0; i < 60 * 30 && !(heard('stomp') && heard('growl')); i++) { update(DT); G.hp = 9; }
|
|
assert.ok(heard('stomp'), 'the boss has feet');
|
|
assert.ok(heard('growl'), 'and a throat: the wind-up draws breath');
|
|
audio.sfxTest.log = null;
|
|
});
|
|
|
|
// ---- THE REMATCH. The relay cannot re-pair (its room code is popped on pairing), so playing the
|
|
// same friend again is the SOCKET THAT IS STILL OPEN: one 'again' frame, both phones reset the run
|
|
// under the decisions the room already carries. The desync to guard against is the two courts
|
|
// coming up different — so these assert the whole handover, from both seats.
|
|
test('netplay rematch: one frame resets both phones onto the same fresh court', () => {
|
|
freshNetDeath('A'); // the host: my serve, death match
|
|
score.games = [2, 1]; score.pts = [3, 0]; G.hp = 2;
|
|
netMsg({ t: 'dead' }); // their blood ran out: the win ending, burst and all
|
|
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
|
|
assert.ok(G.done && G.hideO, 'setup: the summary is up over a burst friend');
|
|
netTest.sent = [];
|
|
assert.ok(netAgain(), 'the line is up: the rematch takes it');
|
|
assert.ok(netTest.sent.some(m => m.t === 'again'), 'and the friend is told');
|
|
assert.ok(G.net && G.net.ready && G.net.death, 'THE PAIRING SURVIVES THE RESET — same room, same mode');
|
|
assert.ok(!G.done && G.mode === 'splatter' && G.match, 'the match machinery owns the court again');
|
|
assert.deepEqual([score.games, score.pts], [[0, 0], [0, 0]], 'nothing carries over');
|
|
assert.equal(G.hp, G.hpMax, 'full blood');
|
|
assert.equal(G.hideO, false, 'and the friend is standing');
|
|
assert.equal(score.server, 'P', 'the host still serves first, on both scoreboards');
|
|
assert.ok(!zombies.some(z => z.variant === 'r'), 'still no roster body — the friend IS the opponent');
|
|
assert.equal(G.modal, null, 'and no DEATH MATCH explainer may eat the first serve');
|
|
|
|
// the other seat — the one that LOST, which is the phone most likely to ask for another: their
|
|
// frame arrives and does the same thing, WITHOUT echoing one back
|
|
freshNetDeath('B');
|
|
G.hp = 1; netMsg({ t: 'pt', win: 'me' });
|
|
assert.equal(G.mode, 'eaten', 'setup: bled out, on the death screen');
|
|
netTest.sent = [];
|
|
netMsg({ t: 'again' });
|
|
assert.ok(G.mode === 'splatter' && G.match && G.net.ready, 'their tap restarts this phone too');
|
|
assert.equal(G.hp, G.hpMax, '...back on its feet with a full pool');
|
|
assert.equal(score.server, 'O', 'the host serves here as well — the two courts agree');
|
|
assert.ok(!netTest.sent.some(m => m.t === 'again'), 'an applied rematch does not echo back out');
|
|
|
|
// BOTH TAPPED AT ONCE: the second frame must not restart a court that is already fresh
|
|
netTest.sent = [];
|
|
score.pts = [2, 0];
|
|
netMsg({ t: 'again' });
|
|
assert.deepEqual(score.pts, [2, 0], 'the echo inside the window is swallowed');
|
|
});
|
|
|
|
test('netplay rematch: a dead line refuses, so the summary falls through to the clubhouse', () => {
|
|
freshNetDeath('A');
|
|
netMsg({ t: 'bye' }); // they left: netDead clears ready
|
|
assert.equal(netAgain(), false, 'no socket, no rematch');
|
|
endNetVs();
|
|
assert.equal(netAgain(), false, 'and none off a court netplay does not own');
|
|
});
|
|
|
|
// classic netplay has no G.match to rebuild — what it must NOT lose is G.classic itself, which the
|
|
// menu set on pairing and which resetState wipes (a rematch that came up in gore mode is the bug)
|
|
test('netplay rematch: the classic duel comes back as classic tennis', () => {
|
|
freshNet('B');
|
|
G.sets = [0, 1]; score.games = [1, 4];
|
|
netAgain();
|
|
assert.equal(G.classic, true, 'the same game is on the court');
|
|
assert.ok(!G.match && G.mode === 'tennis', '...and the death match was never dealt into it');
|
|
assert.deepEqual(score.games, [0, 0], 'a fresh match');
|
|
assert.equal(score.server, 'O', 'the host serves first');
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
test('THE PEER GIFT: cup and its training rep ease from fifteen levels down to parity', () => {
|
|
const peerK = _peerK, legLevel = _legLevel, winLevel = _winLevel;
|
|
G.match = { opp: 15, cup: {} }; // displayed level 41
|
|
prof.prof.xp = prof.xpFor(25);
|
|
assert.equal(peerK(), 0, 'sixteen levels down gets no gift and no penalty');
|
|
const legs25 = legLevel(), win25 = winLevel();
|
|
prof.prof.xp = prof.xpFor(36);
|
|
assert.ok(peerK() > 0.6 && peerK() < 1, 'closing most of the gap earns most of the gift');
|
|
prof.prof.xp = prof.xpFor(41);
|
|
assert.equal(peerK(), 1, 'parity earns the full gift');
|
|
assert.ok(legLevel() / legs25 > 1.1, 'level 41 has materially more reachability than level 25');
|
|
assert.ok(TUNE.swingWindow * (winLevel() - win25) > 0.025, 'the normal contact window gains over 25ms');
|
|
G.match = { opp: 15 }; // arcade: fixed comparison
|
|
assert.equal(peerK(), 0, 'arcade does not soften with opponent parity');
|
|
G.match = { opp: 15, train: true };
|
|
assert.equal(peerK(), 1, 'the exact training rep feels like the cup match');
|
|
G.net = { on: true };
|
|
assert.equal(peerK(), 0, 'netplay never reads profile advantage');
|
|
G.net = null; G.match = null; prof.prof.xp = 0;
|
|
});
|
|
|
|
const recorder = await import('./src/recorder.js');
|
|
const { fault: recordFault } = await import('./src/gameplay.js');
|
|
|
|
test('PLAYTEST: recording the level-26 cup changes neither gameplay nor its random stream', () => {
|
|
const play = enabled => {
|
|
recorder.setRecording(false);
|
|
_seed = 77123;
|
|
freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(26);
|
|
P.dustAcc = 0; // cosmetic distance accumulator survives resetState's Object.assign
|
|
if (enabled) recorder.beginRecording({ build: 'test' });
|
|
_startMatch(true);
|
|
for (let i = 0; i < 180 * 60 && G.mode !== 'eaten' && !G.done; i++) {
|
|
G.modal = null; G.modalQ.length = 0;
|
|
if (G.phase === 'pretoss' && !(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 (d < 2.9 && d > 2.2) keyPressed('Space');
|
|
}
|
|
update(DT);
|
|
}
|
|
return JSON.stringify({ seed: _seed, mode: G.mode, hp: G.hp, score, player: [P.x, P.z],
|
|
ball: [ball.x, ball.y, ball.z, ball.lastHit], opponent: G.match.opp });
|
|
};
|
|
assert.equal(play(true), play(false), 'passive instrumentation leaves the real simulation identical');
|
|
const saved = JSON.parse(recorder.recordingJSON());
|
|
assert.equal(saved.events[0].level, 26);
|
|
assert.equal(saved.events[0].mode, 'cup');
|
|
assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'P' && e.kind === 'serve'));
|
|
assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'O'), 'real opponent returns are captured');
|
|
const points = saved.events.filter(e => e.type === 'point');
|
|
assert.ok(points.length > 0, 'the long baseline rallies reached a scored point');
|
|
assert.ok(points.every(e => e.reason !== 'unspecified' && e.foe && e.state.games && e.run));
|
|
assert.ok(points.some(e => e.tail.length > 10), 'the point carries live movement frames');
|
|
assert.ok(points.every(e => e.tail.length <= 90), 'trajectory storage is bounded');
|
|
});
|
|
|
|
test('PLAYTEST: faults, decided points and fatal losses retain their exact scoring context', () => {
|
|
recorder.beginRecording({ build: 'test' });
|
|
startMatch(); G.interlude.introT = 0;
|
|
recordFault(); recordFault();
|
|
let data = JSON.parse(recorder.recordingJSON());
|
|
assert.equal(data.events.filter(e => e.type === 'fault').length, 2);
|
|
assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]), [['O', 'double_fault']]);
|
|
G.match.decided = true; G.matchEarly = false;
|
|
resolveInterlude('P', 'double_bounce');
|
|
assert.equal(recorder.recordingInfo().points, 1, 'a spent flight cannot record a second verdict');
|
|
G.matchEarly = true;
|
|
resolveInterlude('P', 'unreachable_in_flight');
|
|
G.matchEarly = false; G.match.decided = false; G.hp = 1;
|
|
resolveInterlude('O', 'passed_player'); update(DT);
|
|
data = JSON.parse(recorder.recordingJSON());
|
|
assert.equal(data.points, 3);
|
|
assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]),
|
|
[['O', 'double_fault'], ['P', 'unreachable_in_flight'], ['O', 'passed_player']]);
|
|
assert.equal(data.events.filter(e => e.type === 'point').at(-1).state.hp, 1, 'fatal point recorded before death mutates state');
|
|
assert.equal(data.events.at(-1).outcome, 'lost');
|
|
assert.ok(store.get('splatter-playtest-v1').includes('passed_player'), 'complete points persist on the phone');
|
|
recorder.setRecording(false);
|
|
});
|
|
|
|
test('PLAYTEST: storage failure and the recording limit cannot interfere with scoring', () => {
|
|
recorder.beginRecording(); startMatch();
|
|
const write = localStorage.setItem;
|
|
try {
|
|
localStorage.setItem = () => { throw Error('quota'); };
|
|
resolveInterlude('O', 'passed_player');
|
|
assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'the game still scores when storage is unavailable');
|
|
assert.ok(recorder.recordingInfo().warning.includes('Export'));
|
|
assert.ok(recorder.recordingJSON().includes('passed_player'), 'in-memory export still works');
|
|
} finally { localStorage.setItem = write; }
|
|
recorder.recordEvent('test_limit', { payload: 'x'.repeat(1500001) });
|
|
assert.ok(recorder.recordingInfo().full && !recorder.recordingInfo().active);
|
|
const previous = recorder.recordingInfo().events;
|
|
resolveInterlude('O', 'passed_player');
|
|
assert.equal(recorder.recordingInfo().events, previous, 'a full recording stops without changing the game');
|
|
assert.ok(recorder.recordingJSON().length < 1530000, 'oversized data never enters the export');
|
|
recorder.beginRecording(); recorder.setRecording(false);
|
|
});
|
|
|
|
test('PLAYTEST: Gore and netplay are excluded', () => {
|
|
recorder.beginRecording();
|
|
const before = recorder.recordingInfo().events;
|
|
recorder.recordEvent('shot');
|
|
assert.equal(recorder.recordingInfo().events, before, 'no match, no recorded play');
|
|
startMatch(); G.net = { on: true };
|
|
const matchEvents = recorder.recordingInfo().events;
|
|
recorder.recordEvent('shot'); recorder.recordFrame(DT);
|
|
assert.equal(recorder.recordingInfo().events, matchEvents, 'no network player data');
|
|
G.net = null;
|
|
recorder.setRecording(false);
|
|
});
|
|
|
|
const recoveredRecorder = await import('./src/recorder.js?reload-check');
|
|
test('PLAYTEST: reloading restores saved events without silently starting recording', () => {
|
|
assert.ok(recoveredRecorder.recordingInfo().events > 0);
|
|
assert.equal(recoveredRecorder.recordingInfo().active, false);
|
|
assert.equal(recoveredRecorder.recordingInfo().events, recorder.recordingInfo().events);
|
|
});
|
|
|
|
// File sharing is asynchronous; exercise the actual exporter with browser APIs stubbed, just
|
|
// like the game's platform above. Cancellation keeps the data; an unsupported share downloads.
|
|
{
|
|
const create = document.createElement, share = navigator.share, canShare = navigator.canShare;
|
|
let file, downloads = 0, anchor;
|
|
try {
|
|
navigator.canShare = ({ files }) => { file = files[0]; return true; };
|
|
navigator.share = async () => {};
|
|
assert.equal(await recorder.exportRecording(), 'shared');
|
|
assert.ok(file.name.endsWith('.json'));
|
|
assert.equal(JSON.parse(await file.text()).schema, 1);
|
|
navigator.share = async () => { throw Object.assign(Error('cancelled'), { name: 'AbortError' }); };
|
|
assert.equal(await recorder.exportRecording(), 'cancelled');
|
|
document.createElement = () => (anchor = { click() { downloads++; }, remove() {} });
|
|
navigator.share = async () => { throw Error('share unavailable'); };
|
|
assert.equal(await recorder.exportRecording(), 'download');
|
|
assert.equal(downloads, 1);
|
|
assert.ok(anchor.download.endsWith('.json') && anchor.href.startsWith('blob:'));
|
|
navigator.canShare = () => false;
|
|
assert.equal(await recorder.exportRecording(), 'download');
|
|
assert.equal(downloads, 2);
|
|
assert.ok(recorder.recordingInfo().events > 0, 'export never deletes the saved recording');
|
|
} finally {
|
|
document.createElement = create; navigator.share = share; navigator.canShare = canShare;
|
|
}
|
|
passed++; console.log(' ok - PLAYTEST: file sharing, cancellation and download fallback preserve the export');
|
|
}
|
|
|
|
// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY") ----------------------------------------------
|
|
// One act, played headless through the real modules: the cup (its final is the boss's body), the
|
|
// chapter (no duel, the act's stock, the mid-boss checkpoint, the act boss), the act card, and a death
|
|
// that restarts from the checkpoint instead of ending anything.
|
|
const storyGame = () => { zombies.length = 0; for (let k = 0; k < TUNE.killsPerPoint * 4; k++) splatPoint('P', true); };
|
|
function intoStory(act, cp) {
|
|
prof.prof.story = { act, cp, rack: [], tries: 0 };
|
|
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
|
|
assert.ok(startStory(), 'the story takes the court');
|
|
G.modal = null; G.modalQ.length = 0;
|
|
}
|
|
function winStoryCup() {
|
|
for (let i = 0; i < 120 * 60 && G.story && (G.story.part === 'cup' || G.story.part === 'morph'); i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax;
|
|
if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0))
|
|
resolveInterlude('P', 'test');
|
|
update(DT);
|
|
}
|
|
G.modal = null; G.modalQ.length = 0;
|
|
}
|
|
|
|
test('THE STORY: every act\'s cup final is the body that becomes its boss, and the stock grows act by act', () => {
|
|
const famOf = n => cfg.matchFoe(n).fam;
|
|
const bossFam = { 0: 'gk', 5: 'ma', 2: 'cu', 3: 'hc', 9: 'ch' }; // design -> the ladder body it grew from
|
|
for (let a = 1; a <= 5; a++) {
|
|
const A = cfg.storyAct(a), R = cfg.storyRounds(a);
|
|
assert.equal(R.length, cfg.cupRoundsFor(A.lg).length, `act ${a}: the league cup's own round count`);
|
|
assert.equal(R[R.length - 1].name, 'THE FINAL');
|
|
assert.equal(famOf(R[R.length - 1].band[0]), bossFam[A.boss], `act ${a}: the finalist is the boss's body`);
|
|
const band = cfg.leagueOf(A.lg).band, rungs = R.map(r => r.band[0]).sort((x, y) => x - y);
|
|
assert.deepEqual(rungs, Array.from({ length: band[1] - band[0] + 1 }, (_, i) => band[0] + i), `act ${a}: the whole band, once`);
|
|
}
|
|
const all = new Set([0, 1, 2, 3, 4].flatMap(i => [cfg.STORY.acts[i].mid, cfg.STORY.acts[i].boss]));
|
|
assert.equal(all.size, 10, 'the ten boss designs, each exactly once');
|
|
assert.deepEqual(cfg.storyPool(1), ['bomb', 'triple', 'fire']);
|
|
assert.ok(cfg.storyPool(4).includes('laser') && cfg.storyPool(4).includes('rocket'));
|
|
assert.equal(cfg.storyStock(5)[0].n, cfg.WEAPONS.bomb.tin * 2, 'act V pays double tins');
|
|
assert.deepEqual(cfg.cupRoundsFor(2).map(r => r.band[0]), [5, 6, 7, 8], 'the league cup itself is untouched');
|
|
});
|
|
|
|
test('THE STORY: the act\'s cup is won into its chapter — no promotion, the stock in the rack, full blood', () => {
|
|
intoStory(1, 'cup');
|
|
assert.equal(G.story.part, 'cup');
|
|
assert.ok(G.match && G.match.cup, 'the act opens on its cup');
|
|
winStoryCup();
|
|
assert.ok(G.story && G.story.part === 'chap', 'the won cup hands the court to the chapter');
|
|
assert.ok(!G.match && !G.done, 'no trophy screen, no match left behind');
|
|
assert.equal(prof.takePromo(), null, 'the story moves the league itself — no promotion ceremony');
|
|
assert.equal(prof.prof.story.cp, 'chap', 'the chapter is the checkpoint now');
|
|
assert.deepEqual(G.rack.map(e => e.id), ['bomb', 'triple', 'fire'], 'act I\'s stock is in the rack');
|
|
assert.equal(G.hp, TUNE.playerHp);
|
|
assert.equal(G.runGames, cfg.STORY.base(1), 'the chapter starts at its own game 1');
|
|
});
|
|
|
|
test('THE STORY: the beaten finalist grows into the act boss, runs off, and game 1 begins without a cut', () => {
|
|
intoStory(1, 'cup');
|
|
const cup = G.match.cup;
|
|
cup.round = cup.rounds.length - 1; cup.g = cup.rounds[cup.round].need - 1; // match point of the final
|
|
G.match.opp = cup.rung = cup.rounds[cup.round].band[0];
|
|
let seen = { morph: false, boss: null, grew: 0, left: false, crowd: 0, served: false };
|
|
for (let i = 0; i < 60 * 60 && !(G.story && G.story.part === 'chap'); i++) {
|
|
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax;
|
|
if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0))
|
|
resolveInterlude('P', 'test');
|
|
update(DT);
|
|
if (G.story && G.story.part === 'morph') {
|
|
seen.morph = true;
|
|
assert.ok(G.interlude, 'nothing serves or spawns through the scene');
|
|
const b = zombies.find(z => z.variant === 'B');
|
|
if (b) { seen.boss = b.art.name; seen.grew = Math.max(seen.grew, b.scale); if (b.x < -6) seen.left = true; }
|
|
seen.crowd = Math.max(seen.crowd, zombies.filter(z => z.variant === 'a' || z.variant === 'b').length);
|
|
}
|
|
}
|
|
assert.ok(seen.morph, 'the final ends in the transformation');
|
|
assert.equal(seen.boss, cfg.storyBossArt(1, 'end').name, 'the keeper grows into the groundskeeper');
|
|
assert.ok(seen.left, 'he runs out of the frame');
|
|
assert.ok(seen.crowd >= 3, 'the stands empty onto the court');
|
|
assert.equal(G.story && G.story.part, 'chap', 'game 1 of the chapter');
|
|
assert.ok(!zombies.some(z => z.variant === 'B'), 'the boss is gone until game ten');
|
|
assert.ok(zombies.length >= 3, 'the crowd that came down is the first wave — no cut, no clear');
|
|
assert.ok(!G.interlude, 'play is open');
|
|
});
|
|
|
|
test('THE STORY: the prologue is a living match that turns, and then the first act opens', () => {
|
|
restartRun();
|
|
prof.prof.story = { act: 1, cp: 'pro', rack: [], tries: 0 };
|
|
assert.equal(G.mode, 'tennis', 'a real match');
|
|
assert.ok(startStory());
|
|
G.modal = null; G.modalQ.length = 0;
|
|
assert.equal(G.story.part, 'pro');
|
|
forceZombies(); // the old turn, exactly as written
|
|
for (let i = 0; i < 20 * 60 && !(G.match && G.match.cup); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
|
assert.equal(prof.prof.story.cp, 'cup', 'the prologue is behind you');
|
|
assert.ok(G.match && G.match.cup && G.story && G.story.part === 'cup', 'act I\'s cup takes the court');
|
|
});
|
|
|
|
test('THE STORY: a chapter has no duel, a mid-boss checkpoint at five and the act boss at ten', () => {
|
|
prof.prof.lg = 1;
|
|
intoStory(2, 'chap');
|
|
assert.equal(G.runGames, cfg.STORY.base(2));
|
|
for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; }
|
|
assert.ok(!G.duelDue && !G.interlude, 'no duel was ever called');
|
|
assert.ok(G.bossFight, 'the mid-boss walks on after game five');
|
|
const mid = zombies.find(z => z.variant === 'B');
|
|
assert.equal(mid.art.name, cfg.storyBossArt(2, 'mid').name, 'act II\'s mid-boss is the stringer');
|
|
G.rack = [{ id: 'bomb', n: 1 }]; G.hp = 1; // a hard first half
|
|
zombies.length = 0; endBossFight();
|
|
assert.equal(prof.prof.story.cp, 'mid', 'the mid-boss is the checkpoint');
|
|
assert.equal(G.hp, G.hpMax, 'full blood');
|
|
for (const e of cfg.storyStock(2)) assert.ok(G.rack.find(r => r.id === e.id).n >= e.n, `${e.id} restocked`);
|
|
// the lucky-clear duel trigger: one fresh racket zombie alone in pretoss — never a duel here
|
|
G.bossFight = 0; zombies.length = 0; spawnZombie(0, 2, 'r'); G.phase = 'pretoss';
|
|
update(DT);
|
|
assert.ok(!G.interlude, 'the racket zombie is an ordinary body in the story');
|
|
zombies.length = 0;
|
|
for (let g = 0; g < cfg.STORY.length - cfg.STORY.midAt; g++) { storyGame(); G.modal = null; }
|
|
const boss = zombies.find(z => z.variant === 'B');
|
|
assert.ok(G.bossFight && boss, 'the act boss walks on after game ten');
|
|
assert.equal(boss.art.name, cfg.storyBossArt(2, 'end').name, 'act II\'s boss is the sponsor mascot');
|
|
zombies.length = 0; endBossFight();
|
|
for (let i = 0; i < 20 * 60 && !G.done; i++) { G.modal = null; update(DT); }
|
|
assert.equal(G.done && G.done.story, 2, 'the act is cleared');
|
|
assert.equal(prof.prof.story.act, 3); assert.equal(prof.prof.story.cp, 'cup');
|
|
assert.ok(prof.prof.lg >= 3, 'the next act\'s league opens');
|
|
});
|
|
|
|
test('THE STORY: death is not permadeath — it banks nothing and restarts from the checkpoint', () => {
|
|
intoStory(3, 'chap');
|
|
for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; }
|
|
zombies.length = 0; endBossFight(); // past the mid-boss
|
|
const saved = prof.prof.story.rack.map(e => e.id + e.n).join();
|
|
G.best = null;
|
|
storyGame();
|
|
killPlayer();
|
|
assert.equal(G.mode, 'eaten');
|
|
assert.equal(prof.prof.story.tries, 1, 'the attempt is counted');
|
|
assert.equal(prof.prof.story.cp, 'mid', 'the checkpoint stands');
|
|
assert.equal(G.best, null, 'no board, no run record (endRun never ran)');
|
|
restartRun();
|
|
intoStory(3, 'mid');
|
|
prof.prof.story.tries = 1;
|
|
assert.equal(G.runGames, cfg.STORY.base(3) + cfg.STORY.midAt, 'back at game six');
|
|
assert.ok(G.story.midDone, 'the mid-boss stays beaten');
|
|
assert.equal(G.hp, G.hpMax);
|
|
});
|
|
|
|
test('THE STORY: a barrel holds what the act has earned', () => {
|
|
intoStory(1, 'chap');
|
|
const seen = new Set();
|
|
for (let i = 0; i < 60; i++) {
|
|
zombies.length = 0; G.runGames = cfg.STORY.base(1) + 3;
|
|
const zb = (() => { const z = spawnZombie(0, 0, 'o'); return zombies.find(q => q.variant === 'o') || z; })();
|
|
if (zb && zb.wid) seen.add(zb.wid);
|
|
}
|
|
assert.ok(seen.size >= 2, 'barrels were opened: ' + [...seen]);
|
|
for (const id of seen) assert.ok(cfg.storyPool(1).includes(id), `act I barrel: ${id}`);
|
|
});
|
|
|
|
test('THE STORY: a profile from before the story starts it from the top, keeping its level', () => {
|
|
const s = prof.cleanStory(undefined);
|
|
assert.deepEqual([s.act, s.cp], [1, 'pro']);
|
|
const t = prof.cleanStory({ act: 9, cp: 'nonsense', rack: [{ id: 'bomb', n: 500 }, { n: 2 }], tries: -3 });
|
|
assert.deepEqual([t.act, t.cp, t.tries], [5, 'cup', 0]);
|
|
assert.deepEqual(t.rack, [{ id: 'bomb', n: 99 }]);
|
|
assert.equal(cfg.storyFixture({ act: 1, cp: 'mid' }).line, 'CHAPTER I · FROM GAME 6');
|
|
assert.equal(cfg.storyFixture({ act: 5, cp: 'done' }).title, 'THE STORY IS TOLD');
|
|
});
|
|
|
|
console.log(`\n${passed} tests passed`);
|
|
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|