The latch is stale-proof by construction, the cup divisor honest, the trail visible
Oliver's tournament freeze was the stale latch by a THIRD route: matchFallWillPlay kept the deathmatch /4 while cup matchPoint divides by 4*need, so it promised a fall on a no-crossing point — whose synchronous matchReady left the after-set latch scoping a dead rally. No more ordering arguments: the latch is set BEFORE the resolve, the swallow exempts the in-progress resolution via G.matchEarly, and every path that arms a rally clears it in matchReady. willFall now mirrors the cup divisor exactly and knows a mid-round game keeps its body. Regression test drives the full QF sequence (no-crossing points, the fall, the mid-round game, game two). And the two 'not really' reports: the trail's 0.26 alpha was tuned on the gore palette and vanished on the bright lawn — 0.38 now, gate at 13 m/s; oppBright 1.18 -> 1.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9b26e5380
commit
129a001109
6 files changed
+67
-21
No files matched your search
+1
-1
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=224"></script>
|
<script type="module" src="src/main.js?v=225"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+3
-2
@@ -736,8 +736,9 @@ export function resolveInterlude(side) {
|
|||||||
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
|
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
|
||||||
if (G.match) {
|
if (G.match) {
|
||||||
// a rally resolved at its DECIDED moment (the early fall) is spent: the ball that kept rolling
|
// a rally resolved at its DECIDED moment (the early fall) is spent: the ball that kept rolling
|
||||||
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here
|
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here.
|
||||||
if (G.match.decided) { ball.active = false; return; }
|
// G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs.
|
||||||
|
if (G.match.decided && !G.matchEarly) { ball.active = false; return; }
|
||||||
if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
|
if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
|
||||||
return matchPoint(side);
|
return matchPoint(side);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -46,9 +46,9 @@ export const TUNE = {
|
|||||||
faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip).
|
faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip).
|
||||||
// The rolled cycles hold a real racket, and an un-earned flip teleports it
|
// The rolled cycles hold a real racket, and an un-earned flip teleports it
|
||||||
// across the body — the "racket flashing left and right" report
|
// across the body — the "racket flashing left and right" report
|
||||||
trailSpeed: 16, // m/s of horizontal pace at which a rally ball earns the ghost streak in every
|
trailSpeed: 13, // m/s of horizontal pace at which a rally ball earns the ghost streak in every
|
||||||
// mode (the pow>0.8 gate alone was only ever reached by the gore slingshot)
|
// mode (the pow>0.8 gate alone was only ever reached by the gore slingshot)
|
||||||
oppBright: 1.18, // brightness baked into every OPPONENT sprite copy at load (render.brighten) —
|
oppBright: 1.3, // brightness baked into every OPPONENT sprite copy at load (render.brighten) —
|
||||||
// the far bodies read dark against the crowd. Load-time: reload to retune
|
// the far bodies read dark against the crowd. Load-time: reload to retune
|
||||||
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
|
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
|
||||||
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
|
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
|
||||||
|
|||||||
+19
-14
@@ -1,6 +1,6 @@
|
|||||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
// 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, racketStage } 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, CUP_ROUNDS } from './config.js';
|
||||||
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.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 { 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';
|
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||||
@@ -936,8 +936,14 @@ export function inServeBox(x, z) {
|
|||||||
function matchFallWillPlay() {
|
function matchFallWillPlay() {
|
||||||
const rz = zombies.find(z => z.variant === 'r');
|
const rz = zombies.find(z => z.variant === 'r');
|
||||||
if (!rz) return false;
|
if (!rz) return false;
|
||||||
if (score.pts[0] + 1 >= 4 && score.pts[0] + 1 - score.pts[1] >= 2) return true; // the kill
|
const cup = G.match.cup;
|
||||||
const after = Math.max(1, rz.hp - Math.ceil(rz.hpMax / 4));
|
const gameWin = score.pts[0] + 1 >= 4 && score.pts[0] + 1 - score.pts[1] >= 2;
|
||||||
|
// a game win is only THE KILL when it ends the round — a cup mid-round game keeps the body
|
||||||
|
if (gameWin) return !cup || cup.g + 1 >= CUP_ROUNDS[cup.round].need;
|
||||||
|
// the same divisor matchPoint will use — the first version kept the deathmatch /4 and in the
|
||||||
|
// cup it promised falls that matchPoint then didn't deliver (a route to the stale-latch lock)
|
||||||
|
const div = 4 * ((cup && CUP_ROUNDS[cup.round].need) || 1);
|
||||||
|
const after = Math.max(1, rz.hp - Math.ceil(rz.hpMax / div));
|
||||||
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
|
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,20 +1013,19 @@ function onBounce() {
|
|||||||
// - only when a FALL (or the game-winning kill) will actually play — an ordinary point
|
// - 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;
|
// 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.
|
// - 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
|
// THE LATCH IS SET BEFORE THE RESOLVE AND CLEARED WHEREVER A RALLY ARMS. Version one set
|
||||||
// decided-check swallows the resolution being made. Set AFTER, it must never outlive its
|
// it after and a no-fall point's synchronous matchReady left it stale (the first lock);
|
||||||
// rally — the first shipped version latched after a NO-FALL point, whose matchPoint had
|
// version two argued after-set safe because "every admitted path defers matchReady" — and
|
||||||
// already re-armed the next rally (matchReady runs synchronously there), and the stale
|
// the cup's mid-round game win promptly broke the argument (the second lock, "like freeze
|
||||||
// latch swallowed the whole match: "the fall never happens, the game does not continue,
|
// from before"). No more ordering proofs: the latch goes up BEFORE, the resolution in
|
||||||
// the ball is gone." matchFallWillPlay is what makes after-set safe: both paths it admits
|
// progress is exempted via G.matchEarly in resolveInterlude's swallow, and any path that
|
||||||
// (the collapse, the game-winning kill) defer matchReady until their beat finishes, so the
|
// re-arms play clears it in matchReady. Stale by construction impossible.
|
||||||
// latch always dies with the rally it scoped.
|
|
||||||
else if (G.match && !G.match.ending && !G.match.decided && ball.lastHit === 'P'
|
else if (G.match && !G.match.ending && !G.match.decided && ball.lastHit === 'P'
|
||||||
&& matchFallWillPlay() && matchOutOfReach()) {
|
&& matchFallWillPlay() && matchOutOfReach()) {
|
||||||
G.matchEarly = true; // startFall leaves the ball rolling
|
G.match.decided = true; // scope: the REMAINING ball events of this rally
|
||||||
|
G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us
|
||||||
resolveInterlude('P');
|
resolveInterlude('P');
|
||||||
G.matchEarly = false;
|
G.matchEarly = false;
|
||||||
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); }
|
} else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); }
|
||||||
return;
|
return;
|
||||||
@@ -1784,10 +1789,10 @@ export function update(dt) {
|
|||||||
const arz = zombies.find(z => z.variant === 'r');
|
const arz = zombies.find(z => z.variant === 'r');
|
||||||
const pred = arz && predictLanding();
|
const pred = arz && predictLanding();
|
||||||
if (pred && inCourt(pred.x, pred.z) && matchOutOfReachFlight(arz, pred)) {
|
if (pred && inCourt(pred.x, pred.z) && matchOutOfReachFlight(arz, pred)) {
|
||||||
|
G.match.decided = true; // same latch discipline as the bounce-1 hook below
|
||||||
G.matchEarly = true;
|
G.matchEarly = true;
|
||||||
resolveInterlude('P');
|
resolveInterlude('P');
|
||||||
G.matchEarly = false;
|
G.matchEarly = false;
|
||||||
if (G.match) G.match.decided = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ball.y <= 0 && ball.vy < 0) {
|
if (ball.y <= 0 && ball.vy < 0) {
|
||||||
|
|||||||
+4
-2
@@ -2145,9 +2145,11 @@ export function render() {
|
|||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break;
|
const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break;
|
||||||
const p = proj(h.x, h.y, h.z);
|
const p = proj(h.x, h.y, h.z);
|
||||||
ctx.globalAlpha = 0.26 - i * 0.08;
|
// 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it
|
||||||
|
// vanished entirely — "i see no ball trails" (Oliver, in the tournament)
|
||||||
|
ctx.globalAlpha = 0.38 - i * 0.11;
|
||||||
ctx.fillStyle = UI.ball;
|
ctx.fillStyle = UI.ball;
|
||||||
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1, p.s * 0.09 * (0.8 - i * 0.18)), 0, 7); ctx.fill();
|
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.9 - i * 0.18)), 0, 7); ctx.fill();
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -836,6 +836,44 @@ test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the cha
|
|||||||
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
|
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cup rallies never strand the latch: no-crossing points, the fall, and the mid-round game', () => {
|
||||||
|
// Oliver's second lock: in the quarter-final, matchFallWillPlay still divided by 4 while
|
||||||
|
// matchPoint divided by 4*need — it promised a fall on a point that crossed nothing, the
|
||||||
|
// no-fall path re-armed synchronously, and the after-set latch went stale ("basically like
|
||||||
|
// freeze from before"). The latch is set BEFORE the resolve now (the swallow exempts the
|
||||||
|
// resolution in progress via G.matchEarly), and willFall mirrors the cup divisor exactly.
|
||||||
|
const { CUP_ROUNDS } = cfg;
|
||||||
|
_startMatch(true); G.modal = null; G.modalQ.length = 0;
|
||||||
|
G.mode = 'splatter';
|
||||||
|
const d = G.match;
|
||||||
|
G.interlude.introT = 0;
|
||||||
|
for (let k = 0; k < 4; k++) resolveInterlude('P'); // round one falls to the ordinary path
|
||||||
|
G.modal = null; G.modalQ.length = 0;
|
||||||
|
passMopBeat();
|
||||||
|
assert.equal(d.cup.round, 1, 'on the quarter-final');
|
||||||
|
const zb = oppOf();
|
||||||
|
const launch = () => {
|
||||||
|
zb.x = -4; zb.z = 2;
|
||||||
|
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;
|
||||||
|
const before = score.pts[0] + score.games[0] * 10;
|
||||||
|
for (let i = 0; i < 8 * 60 && score.pts[0] + score.games[0] * 10 === before && !G.matchFall; i++) update(DT);
|
||||||
|
for (let i = 0; i < 6 * 60 && (G.matchFall || G.modal); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
||||||
|
};
|
||||||
|
launch(); // 8 -> 7: crosses nothing (the old mispredict)
|
||||||
|
assert.equal(zb.hp, 7, 'point one chipped the pool');
|
||||||
|
assert.ok(!d.decided, 'no stale latch after a no-crossing point');
|
||||||
|
launch(); // 7 -> 6: still nothing
|
||||||
|
launch(); // 6 -> 5: the arm comes off, mid-flight
|
||||||
|
assert.ok(!d.decided, 'the fall beat ended and released its latch');
|
||||||
|
launch(); // the game point, mid-round: same body plays on
|
||||||
|
assert.ok(zombies.includes(zb), 'game one banked, the quarter-final body stands');
|
||||||
|
assert.equal(score.games[0], 2, 'the game went up');
|
||||||
|
launch(); // game two opens: the match is ALIVE
|
||||||
|
assert.equal(score.pts[0], 1, 'the next rally resolved — no freeze');
|
||||||
|
});
|
||||||
|
|
||||||
test('service alternates by game, and the opponent actually serves', () => {
|
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
|
// 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
|
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return
|
||||||
|
|||||||
Reference in new issue
Block a user