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

+19 -2
View File
@@ -96,11 +96,28 @@ def cut(sheet, fam, outdir):
px = big[big[..., 3] > 200][:, :3]
pal = np.unique((px // 8) * 8, axis=0)
# A FALL ROW IS ANCHORED ON ITS OWN BUCKLE, not on the sheet. The generator draws the fall rows
# noticeably smaller than the standing rows — the brute's buckle came back at 190 px against a
# 224 px stance — and one global scale preserves that faithfully, so the body SHRANK the moment
# it started to fall ("the fall animation seems shrunken"). The first frame of a fall is a body
# still on its feet, so it must draw at standing height; the other three are scaled by the same
# factor, which keeps the collapse's own proportions intact. A row that was already right (the
# racket family's, at 224/224) gets a factor of 1.0 and is untouched — which is the check that
# this rule is the right one rather than a fudge for one bad sheet.
mult = [1.0] * len(crops)
i = 0
for row in want:
if row[0].endswith('fall1') and crops[i].shape[0]:
k_row = crops[0].shape[0] / crops[i].shape[0]
for j in range(i, i + len(row)):
mult[j] = k_row
i += len(row)
os.makedirs(outdir, exist_ok=True)
for c, name in zip(crops, flat):
for c, name, m in zip(crops, flat, mult):
h, w = c.shape[:2]
snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
im = snap.resize((max(1, round(snap.width * k)), max(1, round(snap.height * k))), Image.BOX)
im = snap.resize((max(1, round(snap.width * k * m)), max(1, round(snap.height * k * m))), Image.BOX)
arr = np.array(im).astype(int)
solid = arr[..., 3] > 110
f = arr[..., :3].reshape(-1, 3)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=193"></script>
<script type="module" src="src/main.js?v=194"></script>
</body>
</html>
+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.
+10 -1
View File
@@ -31,7 +31,12 @@ export const TUNE = {
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
// so this is the corner with 17 cm to spare. Raising it past the line does
// not make the flick punchier, it makes a perfect one go out.
flickReach: 0.42, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
flickReach: 0.62, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
// 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then
// overshot it: "flicking: too easy. i must be able to play out." At 0.62
// a mistimed flick CAN sail (~15% of the time at quality 0, on the worst
// half of the scatter) and a perfectly timed one never can — the risk is
// back and it is timing, not a coin toss, that decides it.
// Reach x quality must stay under the sideline once aimError is added on
// top, or the flick goes out — which is exactly the bug this fixed:
// 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
@@ -184,6 +189,10 @@ export const TUNE = {
bossHopX: 2.0, // m of ground one hop covers (the eased arc lands it at about 2/pi of this)
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
// is why he read as "the boss" and why a real boss would not have stood out
matchAsideEvery: 15, // ...but 55 s is a whole match game. In the match his asides are tied to
// things that happen every point or two — a limb, your blood — so the gore
// spacing muted him almost entirely. Same ONE cooldown mechanism, a value
// that fits the mode's own event rate.
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
// acknowledged that the match had changed; now he comments on it, disapprovingly.
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
+1 -1
View File
@@ -482,7 +482,7 @@ export function aside(name, delay = 0.2) {
if (G.mode !== 'splatter' || G.modal || G.changeover || G.card) return;
if (G.interlude && !G.match) return;
if (G.asideT > 0) return;
G.asideT = TUNE.asideEvery;
G.asideT = G.match ? TUNE.matchAsideEvery : TUNE.asideEvery;
voice(name, delay, 0.95);
}
+7 -1
View File
@@ -228,7 +228,13 @@ document.getElementById('nbSend').addEventListener('click', async () => {
}
});
nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nbSend').click(); });
document.getElementById('cStart').addEventListener('click', start);
// () => start(), NEVER `start` — addEventListener hands the listener a MouseEvent as its first
// argument, so `start(modeId = pickedMode)` never saw its default and modeId was an EVENT OBJECT.
// `G.classic = modeId === 'classic'` was therefore always false and the zombie branch never fired:
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
// handler always passes something.
document.getElementById('cStart').addEventListener('click', () => start());
for (const id of ['sBack', 'stBack', 'rkBack', 'scBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
+1 -1
View File
@@ -195,7 +195,7 @@ export function proj(x, y, 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 = 103;
export const SPRV = 104;
// 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".
+6 -1
View File
@@ -198,7 +198,12 @@ export function setupPoint() {
// Is there a stain worth walking out for? Must match mopRound's own target test, or he walks on,
// finds nothing he can reach, and walks straight off again — which is exactly what Oliver saw:
// "the mopboy sometimes moves over the court without anything to mop".
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && d.z >= COURT.netZ - 2);
// ...and in a MATCH the mess is on the FAR side, because that is where the body stands and bleeds
// through four wound stages. This test was written for the gore mode, where everything dies on the
// near court, and it silently made the boy impossible in the match: sendBoy was called, found no
// stain it considered worth walking out for, and returned. The near-side bias is right everywhere
// else, so the match is the exception rather than the rule being widened.
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && (G.match || d.z >= COURT.netZ - 2));
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
// The sudden-death duel is clean tennis: nobody mops through it. THE MATCH IS THE EXCEPTION — it
+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', () => {