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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+21 -8
View File
@@ -223,18 +223,30 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
the art + a scrim gradient as the background stack) with the caption written over the bottom.
Unpicked buttons sit dimmed and desaturated so the chosen mode is the one that glows — the
same "one bright thing means selected" rule the menu palette already follows. */
#modeList button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px;
#modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px;
margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom;
image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit;
text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
filter: saturate(0.45) brightness(0.62); transition: filter 0.15s; }
#modeList button.mode.on { border-color: var(--acid); filter: none; }
#modeList b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); }
#modeList button.mode.on, #modeSub button.mode.on { border-color: var(--acid); filter: none; }
#modeList b, #modeSub b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); }
/* the subtitle carries the mode's one-line rules over busy art, so it gets WHITE at full
opacity, a size up from the court rows' 9px, and a solid black underlay — "barely readable"
at cream-on-crowd was the report from the first live look */
#modeList i { color: #fff; font-size: 10px; margin-bottom: 0;
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0;
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; }
/* THE SECOND STEP (config.MENU): the picked poster's own choices, inset so they read as its
children. Sub-mode cards are the same poster at ~2/3 height, centred on their scene rather
than bottom-anchored (a 64px window off the bottom of these scenes is all court and shins);
the difficulty chips are plain acid-outline buttons — art for EASY/MEDIUM/HARD would be
forced, and the chip row reads faster anyway. */
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; padding-left: 14px; margin: 0 0 8px; }
#modeSub:empty { display: none; }
#modeSub button.mode.sm { flex: 1 1 100%; min-height: 64px; margin: 0; background-position: center; }
#modeSub button.chip { flex: 1; padding: 12px 0; background: var(--panel); border: 2px solid var(--line);
color: var(--cream); font: 400 11px Silkscreen, monospace; letter-spacing: 1px;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#modeSub button.chip.on { border-color: var(--acid); color: var(--acid); background: rgba(188,212,85,0.09); }
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
margin: 0 0 8px; padding: 7px; background: var(--panel);
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
@@ -242,9 +254,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
#courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none;
object-fit: cover; }
#courtList .cb, #modeList .cb { flex: 1; min-width: 0; }
#courtList b, #modeList b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
#courtList i, #modeList i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); font-style: normal; }
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
#courtList b, #modeList b, #modeSub b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
#courtList i, #modeList i, #modeSub i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); font-style: normal; }
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
drift away from what the ball actually does on it. */
.meter { display: flex; align-items: center; gap: 6px; margin-top: 4px;
@@ -370,6 +382,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
<div class="hdr"><button class="chev" id="sBack">&lsaquo;</button><h2>MATCH SETUP</h2></div>
<h3>01 · MODE</h3>
<div id="modeList"></div>
<div id="modeSub"></div>
<h3>02 · PLAYER</h3>
<div id="tours"></div>
<h3>03 · COURT</h3>
@@ -443,6 +456,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=232"></script>
<script type="module" src="src/main.js?v=233"></script>
</body>
</html>
+21 -1
View File
@@ -635,11 +635,31 @@ export const COURTS = [
// baked in — the button's own name/sub render over a scrim, so the caption stays crisp.
export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ZOMBIE TENNIS', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_zombie' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', art: 'mode_gore' },
];
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two
// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
export const MENU = [
{ mode: 'classic', diffs: true },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
{ mode: 'gore' },
];
// CLASSIC's difficulty, the one thing the modes screen adds to gameplay: a scaling on the three
// dials the classic CPU already reads (flub = error rate, quality = shot cap, speed = aiSpeed
// multiplier). MEDIUM is the game exactly as it shipped — classicFlub/classicQualityMax repeated
// here so the middle chip can never drift from what "no difficulty" used to play like.
export const DIFFS = [
{ id: 'easy', name: 'EASY', flub: 0.22, quality: 0.72, speed: 0.88 },
{ id: 'medium', name: 'MEDIUM', flub: 0.10, quality: 0.86, speed: 1.0 },
{ id: 'hard', name: 'HARD', flub: 0.04, quality: 0.95, speed: 1.12 },
];
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
// dead hierarchy, each round DRAWING one rung from its band so runs differ, the final always THE
// CHAMPION. `need` is games to take the round — round one is a single game so a run opens fast —
+17 -7
View File
@@ -1056,11 +1056,19 @@ function onBounce() {
// which points are reachable, and computing that with autoAssist alone meant a desktop match's
// intercept thought NOTHING was reachable and sent the player home every time — he had legs but
// his destination was always the baseline centre.
export const assistK = () => G.match ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist;
// THE RALLY MODES SHARE ONE PLAYER FEEL (Oliver, 2026-08-14: "in classic tennis I tend to miss
// balls way more often — zombie tennis is the good one"). The match's three contact helpers —
// assist floor, matchReach, the short-ball chase — were tuned in the match and classic never got
// them, so the same swing whiffed centimetres short there. Gore's opener keeps the old feel: its
// tennis is a prologue, not the mode.
export const rallyFeel = () => !!(G.match || G.classic);
export const assistK = () => rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist;
function intercept(side) {
const ch = side === 'O' ? O : P;
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * assistK();
// G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the
// classic CPU speeds up or slows down. Same guard on every dial it scales.
const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK();
let { x, y, z, vx, vy, vz } = ball;
const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
@@ -1074,7 +1082,7 @@ function intercept(side) {
// legal contact height by then) and the point went to the zombie. Seven of seven recorded
// losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting
// height: running forward onto the short ball is the tennis answer, and now it is the assist's.
if (side === 'P' && G.match && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) {
if (side === 'P' && rallyFeel() && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) {
const pt = { x, z: Math.min(24.5, z) };
if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) anyHit = pt;
}
@@ -1146,7 +1154,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// sampled 1.3-1.6 m — centimetres outside the 1.3 m post-bounce reach, point after point. The
// assist can deliver you NEAR the ball but not INSIDE a phone-tuned reach; a rally mode lives or
// dies on contact feeling fair, and the gore modes are untouched.
const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1) * (G.match ? TUNE.matchReach : 1);
const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1);
const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
@@ -1712,7 +1720,7 @@ export function update(dt) {
G.aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction;
const tgt = incoming ? intercept('O') : HOME.O;
const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points
const spd = TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points
const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz);
if (d > 0.1) {
O.x += dx / d * Math.min(spd * dt, d);
@@ -1731,11 +1739,13 @@ export function update(dt) {
// CLASSIC TENNIS turns the band OFF and flubs less: the band exists to get a new player
// PAST the opener, and a mode whose whole promise is a real match cannot be won by an
// opponent that falls apart the moment he goes ahead.
const flub = G.classic ? TUNE.classicFlub : TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss;
const flub = G.classic ? (G.diff ? G.diff.flub : TUNE.classicFlub)
: TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss;
if (Math.random() < flub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
else {
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
trySwing(O, true, G.classic ? TUNE.classicQualityMax : TUNE.aiQualityMax); O.swingT = -1;
trySwing(O, true, G.classic ? (G.diff ? G.diff.quality : TUNE.classicQualityMax)
: TUNE.aiQualityMax); O.swingT = -1;
G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12;
}
}
+59 -16
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, MODES, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall } from './beats.js';
@@ -72,27 +72,63 @@ let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// --- MODE tiles, built from config.MODES. The setup screen now picks WHAT, WHO and WHERE, and this
// is the first of the three because it is the one that changes the other two's meaning. Same
// "tap selects, SERVE UP starts" rule as everything else on the screen; the row markup is the court
// row's, reused wholesale (see the shared selectors in index.html).
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
// tennis deals its two sub-mode cards, gore just is gore. `pickedMode` stays the RUN mode id
// (classic/zombie/cup/gore) so start(), stats and the summary never learn about the grouping.
let pickedMode = remembered('mode') || MODES[0].id;
let pickedDiff = remembered('diff') || 'medium';
const modeOf = id => MODES.find(x => x.id === id);
const topOf = m => MENU.find(t => t.mode === m || (t.kids && t.kids.includes(m))) || MENU[0];
const modeList = document.getElementById('modeList');
for (const m of MODES) {
const modeSub = document.getElementById('modeSub');
const banner = (card, cls, on) => {
const b = document.createElement('button');
b.className = 'mode' + (m.id === pickedMode ? ' on' : '');
b.className = cls + (on ? ' on' : '');
// the scrim rides IN the background stack (gradient over art), so the caption needs no extra box
b.style.backgroundImage =
`linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${m.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${m.name}</b><i>${m.sub}</i></span>`;
`linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>`;
return b;
};
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
function buildSub() {
modeSub.innerHTML = '';
const t = topOf(pickedMode);
if (t.diffs) for (const d of DIFFS) {
const b = document.createElement('button');
b.className = 'chip' + (d.id === pickedDiff ? ' on' : '');
b.textContent = d.name;
b.addEventListener('click', () => {
pickedDiff = d.id;
try { localStorage.setItem('diff', d.id); } catch {}
for (const el of modeSub.children) el.classList.toggle('on', el === b);
summary();
});
modeSub.appendChild(b);
}
else if (t.kids) for (const id of t.kids) {
const b = banner(modeOf(id), 'mode sm', id === pickedMode);
b.addEventListener('click', () => {
pickMode(id);
for (const el of modeSub.children) el.classList.toggle('on', el === b);
summary();
});
modeSub.appendChild(b);
}
}
for (const t of MENU) {
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
b.addEventListener('click', () => {
pickedMode = m.id;
try { localStorage.setItem('mode', m.id); } catch {} // come back to what you were playing
// a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
for (const el of modeList.children) el.classList.toggle('on', el === b);
buildSub();
summary();
});
modeList.appendChild(b);
}
buildSub();
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
// the setup screen picks WHO and WHERE, and nothing on it commits you.
@@ -144,13 +180,17 @@ function summary() {
const c = COURTS.find(x => x.id === picked) || COURTS[0];
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun
const mode = m.id === 'zombie' ? stats.MODE_MATCH : m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
// (see cupWon), so its summary reads the same book it writes.
const mode = m.id === 'zombie' || m.id === 'cup' ? stats.MODE_MATCH
: m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
const best = stats.read('all', undefined, mode).longest;
const unit = m.id === 'zombie' ? (best === 1 ? ' FELLED' : ' FELLED')
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
: best === 1 ? ' GAME' : ' GAMES';
const name = m.id === 'classic'
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${m.name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
@@ -178,6 +218,8 @@ function start(modeId = pickedMode) {
// turn switched off, and zombie tennis is the match taking the court. Everything else about a mode
// lives in the code that reads G.classic / G.match, never here.
G.classic = modeId === 'classic';
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
if (modeId === 'cup') { intoSplatter(); startMatch(true); }
}
@@ -185,7 +227,8 @@ let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
// came to look at, and it saves a tap on the way in every single time
const openStats = () => {
stMode = pickedMode === 'zombie' ? stats.MODE_MATCH : pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
stMode = pickedMode === 'zombie' || pickedMode === 'cup' ? stats.MODE_MATCH
: pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
+1 -1
View File
@@ -221,7 +221,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 116;
export const SPRV = 117;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
+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'); });