Give the friend's racket a sound and the duel a rematch

Two netplay-only finds from Oliver's two-phone session.

THE FRIEND'S STRIKE was silent: every other opponent is simulated locally
and cracks through gameplay's shared return path, but the friend's swing
runs on their phone alone and arrives as a 'shot' message that wrote the
ball and nothing else. netApplyShot now plays the same sample at the same
volume, pitched off the pace that was actually sent (strikeRate, read after
the ball is written). Both netplay modes, not just the death match.

THE REMATCH: the summary's only netplay door was THE CLUBHOUSE, so playing
the same friend again meant two trips through the lobby and a fresh code.
The relay cannot re-pair, so the rematch is the socket that is still open —
one 'again' frame and both phones reset the run under the decisions the room
already carries (same side, same mode, same court, host serves). netAgain
keeps G.net across restartRun(true) and replays netFresh, netStart's own
tail split out so pairing and rematch cannot drift into two courts. Applied,
never echoed; a 2 s window swallows the second frame when both tap at once;
a dead line refuses and falls through to the clubhouse.

Also: the dev server runs with SPLAT_AUTH= (server.py always said local dev
should), which the shared password had been 401-ing. audio.sfxTest is a new
test seam in netTest's shape. 146 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-09-11 21:28:31 +02:00
1 parent b53fc07dd1
commit 78946015b4
9 files changed
+189 -17

No files matched your search

+74 -1
View File
@@ -91,7 +91,7 @@ const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
@@ -2115,6 +2115,79 @@ test('netplay death match: bleeding out is said on the wire, and the other phone
assert.equal(G.match.felled, 3, 'felled = the games won on the way');
});
// THE FRIEND'S RACKET (Oliver, 2026-09-11). Every other opponent is simulated locally and cracks
// through gameplay's own strike line; the friend's swing runs on nobody's phone but theirs, so the
// only place the sound can come from is the shot message — and only AFTER the ball it is pitched
// off has been written.
test('netplay: the friend\'s strike makes a noise, pitched off the ball it just sent', () => {
freshNet('B');
audio.sfxTest.log = [];
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
assert.ok(audio.sfxTest.log.includes('tennis-opponent'), 'the far racket cracks on the wire event');
audio.sfxTest.log = null;
});
// ---- THE REMATCH. The relay cannot re-pair (its room code is popped on pairing), so playing the
// same friend again is the SOCKET THAT IS STILL OPEN: one 'again' frame, both phones reset the run
// under the decisions the room already carries. The desync to guard against is the two courts
// coming up different — so these assert the whole handover, from both seats.
test('netplay rematch: one frame resets both phones onto the same fresh court', () => {
freshNetDeath('A'); // the host: my serve, death match
score.games = [2, 1]; score.pts = [3, 0]; G.hp = 2;
netMsg({ t: 'dead' }); // their blood ran out: the win ending, burst and all
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
assert.ok(G.done && G.hideO, 'setup: the summary is up over a burst friend');
netTest.sent = [];
assert.ok(netAgain(), 'the line is up: the rematch takes it');
assert.ok(netTest.sent.some(m => m.t === 'again'), 'and the friend is told');
assert.ok(G.net && G.net.ready && G.net.death, 'THE PAIRING SURVIVES THE RESET — same room, same mode');
assert.ok(!G.done && G.mode === 'splatter' && G.match, 'the match machinery owns the court again');
assert.deepEqual([score.games, score.pts], [[0, 0], [0, 0]], 'nothing carries over');
assert.equal(G.hp, G.hpMax, 'full blood');
assert.equal(G.hideO, false, 'and the friend is standing');
assert.equal(score.server, 'P', 'the host still serves first, on both scoreboards');
assert.ok(!zombies.some(z => z.variant === 'r'), 'still no roster body — the friend IS the opponent');
assert.equal(G.modal, null, 'and no DEATH MATCH explainer may eat the first serve');
// the other seat — the one that LOST, which is the phone most likely to ask for another: their
// frame arrives and does the same thing, WITHOUT echoing one back
freshNetDeath('B');
G.hp = 1; netMsg({ t: 'pt', win: 'me' });
assert.equal(G.mode, 'eaten', 'setup: bled out, on the death screen');
netTest.sent = [];
netMsg({ t: 'again' });
assert.ok(G.mode === 'splatter' && G.match && G.net.ready, 'their tap restarts this phone too');
assert.equal(G.hp, G.hpMax, '...back on its feet with a full pool');
assert.equal(score.server, 'O', 'the host serves here as well — the two courts agree');
assert.ok(!netTest.sent.some(m => m.t === 'again'), 'an applied rematch does not echo back out');
// BOTH TAPPED AT ONCE: the second frame must not restart a court that is already fresh
netTest.sent = [];
score.pts = [2, 0];
netMsg({ t: 'again' });
assert.deepEqual(score.pts, [2, 0], 'the echo inside the window is swallowed');
});
test('netplay rematch: a dead line refuses, so the summary falls through to the clubhouse', () => {
freshNetDeath('A');
netMsg({ t: 'bye' }); // they left: netDead clears ready
assert.equal(netAgain(), false, 'no socket, no rematch');
endNetVs();
assert.equal(netAgain(), false, 'and none off a court netplay does not own');
});
// classic netplay has no G.match to rebuild — what it must NOT lose is G.classic itself, which the
// menu set on pairing and which resetState wipes (a rematch that came up in gore mode is the bug)
test('netplay rematch: the classic duel comes back as classic tennis', () => {
freshNet('B');
G.sets = [0, 1]; score.games = [1, 4];
netAgain();
assert.equal(G.classic, true, 'the same game is on the court');
assert.ok(!G.match && G.mode === 'tennis', '...and the death match was never dealt into it');
assert.deepEqual(score.games, [0, 0], 'a fresh match');
assert.equal(score.server, 'O', 'the host serves first');
});
test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {
assert.ok(_startMatch(), 'solo match on a clean court');
G.modal = null; G.modalQ.length = 0;