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:
1 parent
53df079db6
commit
f991f1ab10
3 files changed
+54
-6
No files matched your search
+1
-1
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user