The menu grows a second step, classic learns difficulty, and the rally feel unifies

Oliver's restructure: mode first, then what the mode asks — classic deals
EASY / MEDIUM / HARD chips, ZOMBIE TENNIS deals two sub-mode posters (ARCADE,
the exhibition ladder as was, and THE TOURNAMENT), and SPLATTER GORE stands
alone as the third top card. config.MENU is the grouping, MODES stays the four
run modes, and pickedMode stays the run-mode id — start(), stats and the
summary never learn the menu got taller. The sub-row sits inset under the
posters; sub-cards are the poster at 2/3 height centred on their scene.

DIFFICULTY is the one real gameplay addition: config.DIFFS scales the three
dials the classic CPU already reads (flub, quality cap, aiSpeed), MEDIUM
pinned by test to the shipped classicFlub/classicQualityMax so the middle chip
IS yesterday's game. Rides on G.diff next to G.classic, so restarts keep it.

AND THE FEEL SPLIT OLIVER REPORTED IS REAL: "in classic tennis I tend to miss
balls way more often" — the match's three contact helpers (matchReach 1.35,
the matchAssist floor, the short-ball chase) were all G.match-gated, so
classic played with a 35% smaller racket and no rescue on the dying drop
shot. rallyFeel() = match || classic now carries all three; gore's opener
keeps the old prologue feel on purpose.

The ARCADE poster took two rolls: the first came back a flat sticker line-up
("very constructed, not artsy"), the second anchors on the zombie poster
itself as a MOOD reference — one challenger lit at the net, the queue receding
into purple haze. Rolls in _refcheck/modebtn. The cup's stats mapping also
joins MODE_MATCH (it banks through endRun as MATCH and read back as GORE).
SPRV 117, main.js v233, 66 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 10:29:51 +02:00
1 parent d3bbd8e157
commit e4c917e23d
7 files changed
+141 -34

No files matched your search

+22 -1
View File
@@ -83,7 +83,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY } = await import('./src/gameplay.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.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
@@ -1104,6 +1104,27 @@ test('classic tennis: losing the match ends it the same way, and counts as a los
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, with MEDIUM pinned to the shipped classic dials so the
// middle chip can never drift from what "no difficulty" used to play like.
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.equal(m.flub, TUNE.classicFlub, 'MEDIUM flub IS classicFlub');
assert.equal(m.quality, TUNE.classicQualityMax, 'MEDIUM quality IS classicQualityMax');
assert.equal(m.speed, 1.0, 'MEDIUM leaves aiSpeed alone');
});
// 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'); });