SERVE UP was dropping you into gore whatever you picked

THE MENU BUG, and it explains three reports at once. cStart's handler was
`addEventListener('click', start)` — and addEventListener hands a listener the
MouseEvent as its first argument, so `start(modeId = pickedMode)` never saw its
default: modeId was an EVENT OBJECT. `G.classic = modeId === 'classic'` was
therefore always false and the zombie branch never fired. Picking CLASSIC or
ZOMBIE TENNIS on the setup screen did nothing at all; SERVE UP always started
the gore mode. A default parameter only applies to `undefined`, and a click
handler always passes something. `() => start()`.

That is also why "in normal tennis i dont see the ball circles and the serve
hint bar": there was no normal tennis to be in. Both are drawn unconditionally
in render() and the tennis struggle hints are already mode-gated correctly.
Verified through the real menu after the fix: CLASSIC gives mode=tennis with
no bodies on court, ZOMBIE gives match=true against the SHAMBLER in its own
body. Every other bare handler in the codebase takes no parameters — checked.

THE MOP BOY had the same class of bug one layer down. sendBoy was called and
mopWorthIt() refused: it tests `d.z >= COURT.netZ - 2`, the NEAR half, because
in the gore mode everything dies on the near court. A match's blood is all on
the FAR side, where the body stands and bleeds through four wound stages, so
the boy could never qualify. The near-side bias stays; the match is the
exception.

DANIEL was being dropped, not missing. Two causes, both mine: the asides were
queued at 0.7-1.5 s and matchPoint fires the score call at 0.4 s with a clip
over a second long — voice() drops anything that would start while he is still
talking (one man, one microphone). And asideEvery is 55 s, a whole match game.
Delays moved past the score call; matchAsideEvery is 15 s, which fits a mode
whose events are a limb and your blood rather than a wave of bodies.

THE FALL: quicker down, longer on the floor (0.09/0.11/2.0/0.30). And it was
genuinely SHRUNKEN — the generator draws fall rows smaller than standing rows
(the brute's buckle came back 190 px against a 224 px stance) and _zsheet's one
global scale preserved that faithfully, so the body shrank the moment it
started to fall. A fall row is anchored on its own buckle now, which must draw
at standing height. All seven generated families re-cut: 224/153/98/181 against
the hand-made racket family's 224/154/85/179 — the convergence is the check.

THE FLICK: 0.42 made going out impossible, which fixed the 8-in-10 bug and
overshot it ("too easy. i must be able to play out"). 0.62 puts risk back where
it belongs — ~15% out at quality 0, 5% at 0.75, and never on a perfect flick.
The test now asserts that shape: bounded, monotonically falling, zero at 1.

59 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 20:07:02 +02:00
1 parent 52b38e4a39
commit e1ce2486c0
93 files changed
+70 -23

No files matched your search

+9 -4
View File
@@ -364,7 +364,9 @@ function matchReady() {
// fall3 is the one that HOLDS: "the opponent should fall and then lie there for a quick moment, the
// player stands up too quick." A corpse that touches the ground and is instantly upright reads as a
// stumble; the beat only lands if it is down long enough to look beaten.
const FALL = [['fall1', 0.14], ['fall2', 0.16], ['fall3', 0.62], ['rise', 0.36]];
// "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth
// of a second — and the time is spent on the floor, where it reads.
const FALL = [['fall1', 0.09], ['fall2', 0.11], ['fall3', 2.0], ['rise', 0.30]];
export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0);
function startFall(from) {
@@ -468,7 +470,10 @@ function matchPoint(side) {
aww(0.8);
// ...and the umpire notices, drily. Situational, never a pool on a timer: the LOW line only
// exists once the blood is nearly gone, so it lands as a warning rather than as commentary.
aside(G.hp <= 2 ? 'ump_low_blood' : (score.pts[1] % 2 ? 'ump_wound_you2' : 'ump_wound_you'), 0.7);
// AFTER the score call, not under it. voice() drops any line that would start while the umpire
// is still talking (one man, one microphone) — and matchPoint calls scoreKey() at 0.4 s with a
// clip over a second long, so every one of these was being swallowed silently.
aside(G.hp <= 2 ? 'ump_low_blood' : (score.pts[1] % 2 ? 'ump_wound_you2' : 'ump_wound_you'), 2.2);
if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever
}
const over = advancePoint(side); // banks the game and resets the points itself
@@ -479,7 +484,7 @@ function matchPoint(side) {
// A LIMB IS WORTH A REMARK. Two lines, alternating on the stage taken, so the same sentence
// never lands twice in one game — a repeated aside reads as a stuck HUD, the same reason the
// praise words cycle rather than roll.
aside(fellFrom ? 'ump_wound_opp2' : 'ump_wound_opp', 0.9);
aside(fellFrom ? 'ump_wound_opp2' : 'ump_wound_opp', 2.2); // ...after the score call, see above
return startFall(fellFrom); // it goes down, and gets up short of a limb
}
matchReady(); // next point, same opponent, both on their marks
@@ -495,7 +500,7 @@ function matchGameWon(zb, banked = false) {
G.runGames++; // the ladder metric, same as every mode
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
voice('game_player', 0.3); cheer(2.4, 0.6);
aside('ump_felled', 1.5); // ...over the applause, as he would
aside('ump_felled', 2.8); // ...after 'game', over the applause
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
// once — and the plate is the better card anyway: it announces the win by replacing the loser.