Fix the early-fall soft-lock: the decided latch must die with its rally

Oliver's retest: 'the fall never happens, the game does not continue, the
ball is gone.' Root cause: the decided latch was set AFTER resolveInterlude,
and on a point with no stage change matchPoint re-arms the next rally
synchronously — so the latch outlived the rally it scoped and swallowed
every resolution after it.

Two changes: matchFallWillPlay() gates the early resolution to points that
actually put a collapse (or the game-winning kill) on court — an ordinary
point keeps its ordinary bounce-2 ending, so the ball never vanishes
mid-roll — and with only those paths admitted, the after-set latch is
provably safe: both defer matchReady until their beat ends. A regression
test drives the exact soft-lock sequence through the real update loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 21:52:44 +02:00
1 parent 53df079db6
commit f991f1ab10
3 files changed
+54 -6

No files matched your search

+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=220"></script>
<script type="module" src="src/main.js?v=221"></script>
</body>
</html>
+27 -5
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
@@ -930,6 +930,17 @@ export function inServeBox(x, z) {
return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK;
}
// Would winning THIS point put a collapse (or the game-winning kill) on court? Mirrors
// matchPoint's own wound math and advancePoint's game test — if the answer is no, there is
// nothing for an early resolution to show, and the rally keeps its ordinary ending.
function matchFallWillPlay() {
const rz = zombies.find(z => z.variant === 'r');
if (!rz) return false;
if (score.pts[0] + 1 >= 4 && score.pts[0] + 1 - score.pts[1] >= 2) return true; // the kill
const after = Math.max(1, rz.hp - Math.ceil(rz.hpMax / 4));
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
}
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
// chase speed — the same dials racketAI moves with — plus a full swing reach and a 0.6 m benefit
@@ -971,13 +982,24 @@ function onBounce() {
// this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just
// bounced in and is provably beyond the body's reach has already decided the point, so it
// resolves HERE: the collapse starts while the ball still rolls past, and the picture reads
// "he fell, so it got by". Conservative on purpose — a borderline ball simply waits for the
// ordinary bounce-2 call, because a wrong early call hands over a rally that was alive.
else if (G.match && !G.match.ending && !G.match.decided && ball.lastHit === 'P' && matchOutOfReach()) {
// "he fell, so it got by". Two deliberate limits:
// - only when a FALL (or the game-winning kill) will actually play — an ordinary point
// keeps its ordinary bounce-2 ending, or the ball would vanish mid-roll for nothing;
// - conservative reach test — a borderline ball waits for the ordinary call.
// THE LATCH ORDER IS LOAD-BEARING, both ways. Set BEFORE the resolve, the hook's own
// decided-check swallows the resolution being made. Set AFTER, it must never outlive its
// rally — the first shipped version latched after a NO-FALL point, whose matchPoint had
// already re-armed the next rally (matchReady runs synchronously there), and the stale
// latch swallowed the whole match: "the fall never happens, the game does not continue,
// the ball is gone." matchFallWillPlay is what makes after-set safe: both paths it admits
// (the collapse, the game-winning kill) defer matchReady until their beat finishes, so the
// latch always dies with the rally it scoped.
else if (G.match && !G.match.ending && !G.match.decided && ball.lastHit === 'P'
&& matchFallWillPlay() && matchOutOfReach()) {
G.matchEarly = true; // startFall leaves the ball rolling
resolveInterlude('P');
G.matchEarly = false;
if (G.match) G.match.decided = true; // every later resolution of THIS rally is already spent
if (G.match) G.match.decided = true; // scope: the REMAINING ball events of this rally
}
} else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); }
return;
+26
View File
@@ -785,6 +785,32 @@ test('the fall beats the ball: a passing winner fells the body while the ball st
assert.ok(!G.match.decided, 'the latch cleared for the next rally');
});
test('an ordinary far winner keeps its ordinary ending, and the match plays on (the soft-lock)', () => {
// The first shipped version of the early fall latched `decided` AFTER resolveInterlude — and on
// a point with no stage change matchPoint re-arms the next rally synchronously, so the latch
// outlived its rally and swallowed every resolution after it: no falls, no points, no serve,
// ever again. Oliver: "the fall never happens, the game does not continue, the ball is gone."
startMatch();
G.mode = 'splatter';
G.interlude.introT = 0;
const zb = oppOf(); // full pool: this point crosses NO stage
zb.x = -4; zb.z = 2;
const launch = () => {
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
G.phase = 'rally'; G.wasServe = false;
};
launch();
const pts = score.pts[0];
for (let i = 0; i < 5 * 60 && score.pts[0] === pts; i++) update(DT);
assert.equal(G.matchFall, null, 'no stage to show: no early fall, the ordinary ending played');
assert.equal(score.pts[0], pts + 1, 'the point banked once');
assert.ok(!G.match.decided, 'and the latch is not left armed');
launch(); // THE NEXT RALLY MUST STILL RESOLVE — the lock test
for (let i = 0; i < 5 * 60 && score.pts[0] === pts + 1; i++) update(DT);
assert.equal(score.pts[0], pts + 2, 'the match plays on');
});
test('service alternates by game, and the opponent actually serves', () => {
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return