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

+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