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

+16 -11
View File
@@ -668,24 +668,29 @@ test('the first opponent cannot paint lines: its returns are width-clamped', ()
assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung');
});
test('an AIMED return cannot be sent out by its own aim, at any timing', () => {
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
for (let q = 0; q <= 1.0001; q += 0.05) {
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
const worst = aimW * reach + TUNE.aimError * (1 - q);
assert.ok(worst <= line,
`aimed reach + worst scatter must stay inside the line at quality ${q.toFixed(2)}: ` +
`${worst.toFixed(2)} vs ${line.toFixed(3)}`);
}
// ...and it must still be worth flicking: perfect timing keeps the full width.
assert.ok(aimW * (TUNE.flickReach + (1 - TUNE.flickReach)) > line * 0.75,
'a perfectly timed flick still reaches the corner');
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)');
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 wounding point knocks the body down and it gets up MINUS the limb', () => {