diff --git a/index.html b/index.html index fd3cf54..42a76a5 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/gameplay.js b/src/gameplay.js index 4c42727..2bfd23d 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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; diff --git a/test.js b/test.js index 651130e..c6fe086 100644 --- a/test.js +++ b/test.js @@ -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