From 78946015b49d2e95c099f938967c5727e00a3e89 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 11 Sep 2026 21:28:31 +0200 Subject: [PATCH] Give the friend's racket a sound and the duel a rematch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/launch.json | 2 +- PROJECT.md | 36 ++++++++++++++++++++++ index.html | 2 +- src/audio.js | 2 ++ src/gameplay.js | 8 +++-- src/main.js | 6 +++- src/net.js | 51 ++++++++++++++++++++++++++++-- src/render.js | 24 ++++++++++----- test.js | 75 ++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 189 insertions(+), 17 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 456e23a..52ad180 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,7 +4,7 @@ { "name": "splatter-tennis", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "PORT=${PORT:-8641} SPLAT_DATA=/tmp/splatter-scores python3 server.py"], + "runtimeArgs": ["-c", "PORT=${PORT:-8641} SPLAT_DATA=/tmp/splatter-scores SPLAT_AUTH= python3 server.py"], "port": 8641, "autoPort": true } diff --git a/PROJECT.md b/PROJECT.md index d256fe4..5ec4b81 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5063,6 +5063,42 @@ Release cache version: `main.js?v=353`. Pre-release validation: 142 game tests a menu browser checks pass. Deploy staging first, verify the served release, then promote the same commit to production. Staging URL: `https://splatter-tennis-staging.wuwi.one`. +## Session log: VS A FRIEND — the friend's racket makes a noise, and the duel has a rematch (2026-09-11, v354) + +Oliver, two finds off a two-phone session: *"im vs. a friend mode wird der ballsound des gegners +nicht abgespielt (zumindest deathmatch)"* and *"nach einem vs. a friend duell gibt es kein retry / +replay button"*. Both are netplay-only by construction, which is why neither had been seen before. + +- **THE FRIEND'S STRIKE HAD NOWHERE TO COME FROM.** Every other opponent in the game is simulated + locally and cracks through gameplay's own strike line (`sfx('tennis-opponent', …)` in the shared + return path, which is why the CPU has always sounded right). The friend's swing runs on nobody's + phone but theirs — it arrives as a `shot` message and `netApplyShot` writes the ball straight + into the world — so half of every netplay rally was silent, in BOTH netplay modes (the report + hedged "at least deathmatch"; it was never mode-specific). One line at the end of `netApplyShot`, + after the ball is written so `strikeRate()` pitches off the pace that was actually sent. + `gameplay.strikeRate` is exported for it, and `audio.sfxTest` is a new test seam in the shape of + `netTest.sent`, so the suite can assert the crack instead of trusting the ear. +- **THE REMATCH.** The summary's only netplay door was THE CLUBHOUSE: playing the same person again + meant both of them walking back through the lobby and reading a fresh code aloud, for the one + mode whose whole point is the person opposite. The relay cannot re-pair (its room code is popped + on pairing, by design), 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. `net.netAgain()` keeps `G.net` across `restartRun(true)` (the new flag + is the whole change to that function) and replays `netFresh()`, which is netStart's own tail + split out so the pairing and the rematch cannot drift into two different courts. It is applied, + never echoed (the rule every verdict in that file follows), and a 2 s window swallows the second + frame when both players tap at once. A dead line refuses, so the button falls through to the + clubhouse exactly as before. +- **The summary grows its second door in netplay** (`online` in render.js): REMATCH filled, THE + CLUBHOUSE outlined, NEXT reading ANOTHER DUEL · YOUR FRIEND IS STILL ON THE LINE. It is drawn on + the loser's BLED OUT screen too — the phone most likely to want another one. +- **`.claude/launch.json` now runs the dev server with `SPLAT_AUTH=`**, which server.py's own note + always said local dev should do. The shared password (2026-09-08) had been 401-ing the browser + pane, and a preview that cannot load is a verification round lost every session. +- Verified end to end through the real relay in two panes: pair → death match → REMATCH on the host + → both courts come up 0-0 with full blood, the friend standing, no gore, "REMATCH — YOUR SERVE" + against "REMATCH — THEIR SERVE". 146 tests. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 82468bb..f579728 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/audio.js b/src/audio.js index 10dba2f..f1c62ac 100644 --- a/src/audio.js +++ b/src/audio.js @@ -122,7 +122,9 @@ export function voiceSeq(names, gap = 0.12) { // Recorded one-shot SFX (assets/audio/tennis-*.wav): ball hits, applause, the crowd's aww. // Same load-once-then-cache pattern as voice(), but dry (no PA reverb) and with a volume knob. const SFX = {}; +export const sfxTest = { log: null }; // test seam: an array here catches the names played (netTest's pattern) export function sfx(name, vol = 1, opts = {}) { + if (sfxTest.log) sfxTest.log.push(name); const ac = audio(); const play = buf => { const src = ac.createBufferSource(); src.buffer = buf; diff --git a/src/gameplay.js b/src/gameplay.js index 9e94927..284f63d 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -22,7 +22,7 @@ import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFau // dink at 0.93, a 52 m/s serve at 1.09. Deliberately narrow: past about a tenth either way the // sample stops sounding like the same racket. Read off the LIVE ball, so it is one call site's // worth of code for every strike in the game. -const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200)); +export const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200)); // ...and a fast ball KICKS THE CAMERA as it leaves. The third answer to the same report: the trail // says how fast the ball is, the crack says how hard it was hit, and this says it in the one channel // the striker cannot miss, because it moves the whole picture rather than something inside it. @@ -1031,9 +1031,11 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2); // is supposed to pick up the track that was already playing over the death summary. Every mutable // bag has a pristine copy (state.resetState) and the two modules with timers of their own put them // back themselves, so nothing here has to know the shape of any of it. -export function restartRun() { +export function restartRun(keepNet = false) { recordEvent('run_end', { outcome: G.done?.won ? 'won' : G.mode === 'eaten' ? 'lost' : 'abandoned' }); - netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried + // netplay: resetState wipes G.net below; the socket must not outlive the run it carried — unless + // the REMATCH is what asked for the reset, in which case the same pairing carries the next one + if (!keepNet) netClose(); resetZombies(); resetInput(); resetState(); setupPoint(); beginRun(); // ...and the stats bag, which is the only state that does not live on G beginRunXp(); // ...and the profile's run delta, same rule (the xp itself is already banked) diff --git a/src/main.js b/src/main.js index bdbe616..a0c1392 100644 --- a/src/main.js +++ b/src/main.js @@ -7,7 +7,7 @@ import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DO import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; -import { netConnect, netHooks } from './net.js'; +import { netConnect, netHooks, netAgain } from './net.js'; import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; @@ -1019,6 +1019,10 @@ const maybeRestart = e => { const r = cv.getBoundingClientRect(); door = endHit((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height); } + // THE REMATCH is the one door that does not end the run here: the socket is still up, so net.js + // resets both phones itself and the menu never comes back. It refuses (and falls through to the + // clubhouse) if the friend has already hung up. + if (door === 'rematch' && netAgain()) return; restartRun(); profile.push(); // the run's xp is banked — mirror it while the moment is quiet refreshSplash(); // the run just ended: the splash count has moved diff --git a/src/net.js b/src/net.js index d91e1b1..b1721c9 100644 --- a/src/net.js +++ b/src/net.js @@ -19,9 +19,10 @@ // plan calls reconnection "the bulk of the real work" and a prototype must not start there. import { COURT, UI, KITS, SKINS, TOURS, NATIVE, HOST } from './config.js'; import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin, tour } from './state.js'; -import { pointTo, fault, tossBall } from './gameplay.js'; +import { pointTo, fault, tossBall, strikeRate, restartRun } from './gameplay.js'; import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module import { zombies } from './zombies.js'; +import { sfx } from './audio.js'; import { savedName, tidyName } from './stats.js'; // main.js hangs its court builders and its LOBBY here, so this module never has to know how a @@ -84,6 +85,12 @@ export function netApplyShot(m) { G.phase = 'rally'; G.swung = false; G.rallyNear = null; O.x = s.x; O.z = s.z; // the strike happened where the ball says it did O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0; + // ...AND IT MAKES A NOISE (Oliver, 2026-09-11: "der ballsound des gegners wird nicht abgespielt"). + // Every other opponent in the game is simulated locally and cracks through gameplay's own strike + // line; the friend's racket is the ONE swing no phone but theirs ever runs, so the sound had + // nowhere to come from and a netplay rally was half silent. Same sample, same volume and the same + // pace-pitched rate the CPU's gets — read off the ball we have just been handed. + sfx('tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); } function netApplyPoint(m) { @@ -140,6 +147,18 @@ function netStart(side, death, courtId) { // guest never chose. Safe mid-run: selectCourt copies the bounce pair into TUNE and the backdrop // cache keys on court.id, so the next frame repaints. if (courtId && courtId !== court.id) selectCourt(courtId); + netFresh(side, death); +} + +// TAKE THE COURT AND PLAY: everything netStart does once the pairing is settled, and everything a +// REMATCH has to do all over again on the same socket. Split out for netAgain below — the two +// must not drift, because a rematch that sets the court up even slightly differently from the +// pairing is a desync waiting for the first serve. +function netFresh(side, death, again = false) { + // WHICH GAME IS ON THIS COURT. The first time round netHooks.paired -> main.start('classic') + // sets this; on a rematch the menu is long gone and start() refuses, so it is said here instead + // (the death branch below turns it off again exactly as netStart always did). + G.classic = !death; if (death && !G.match) { // THE HOST PICKED DEATH MATCH, and the start message carries it to both phones — the joiner // (and the host itself) has been waiting on an ordinary classic court, so the court is rebuilt @@ -166,7 +185,34 @@ function netStart(side, death, courtId) { score.pts = [0, 0]; G.serveNum = 1; if (G.match) matchReady(); else setupPoint(); - G.flash = { text: side === 'A' ? 'AN OPPONENT — YOUR SERVE' : 'AN OPPONENT — THEIR SERVE', t: 2.4 }; + G.flash = { text: (again ? 'REMATCH' : 'AN OPPONENT') + + (side === 'A' ? ' — YOUR SERVE' : ' — THEIR SERVE'), t: 2.4 }; +} + +// THE REMATCH (Oliver, 2026-09-11: "nach einem vs. a friend duell gibt es kein retry / replay +// button"). The summary's only door was THE CLUBHOUSE, so playing the same friend again meant both +// of them walking back through the lobby and reading a fresh code aloud — for a mode whose whole +// point is the person sitting opposite you. The relay cannot re-pair (the room code is single-use +// and popped on pairing), so the rematch is the SOCKET THAT IS STILL OPEN: one 'again' frame and +// both phones reset their run and take the court under the decisions the room already carries — +// same side, same mode, same court. +// It lives here rather than beside startNetVs in beats.js because it is netStart's own path +// replayed: the court-ownership contract was settled when the room paired and is never re-entered. +// `tell` is false when THEY asked: a rematch is applied, never echoed back (same rule as every +// verdict in this file — see the `applying` latch). +export function netAgain(tell = true) { + const n = G.net; + if (!n || !n.ready || n.dead) return false; // the line has to still be there + // BOTH TAPPED AT ONCE: their 'again' lands a moment after ours and would restart a court that is + // already fresh — mid-toss, if they were quick. One restart per handshake, either way round. + const now = performance.now(); + if (now - (n.againT || -1e9) < 2000) return true; + n.againT = now; + if (tell) send({ t: 'again' }); + restartRun(true); // a fresh run, and the pairing rides through it + G.net = n; + netFresh(n.side, !!n.death, true); + return true; } function netDead(text) { @@ -196,6 +242,7 @@ export function netMsg(m) { else if (m.t === 'pt') netApplyPoint(m); else if (m.t === 'fault') netApplyFault(); else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending + else if (m.t === 'again') netAgain(false); // they tapped REMATCH: the same room, one run later else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); } // WHAT THEY LOOK LIKE AND WHAT THEY ARE CALLED. Their phone's kit and skin, resolved against our // own tables so a build that has never heard of a colour falls back to the drawn one rather than diff --git a/src/render.js b/src/render.js index 288a74b..c1a3a2c 100644 --- a/src/render.js +++ b/src/render.js @@ -3088,12 +3088,18 @@ export function render() { : G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' : 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS') : null; + // THE FRIEND IS STILL ON THE LINE — so the duel has a door back into itself (the REMATCH, + // 2026-09-11). Netplay used to end on THE CLUBHOUSE alone, which meant a fresh room code and + // a second trip through the lobby to play the same person again. + const online = !promo && !!(G.net && G.net.ready && !G.net.dead); const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD' + : online ? 'YOUR FRIEND IS STILL ON THE LINE' : G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : []; let h = pad + headLines.length * 24 + lineLines.length * 13 + 6; if (xd.xp > 0) h += MODAL_GAP + 32; if (G.newBest || mark) h += MODAL_GAP + (G.newBest ? 20 : 13); - const twoUp = ready && !promo && !G.net; // is there a second door to draw? + const solo = !promo && !G.net; + const twoUp = ready && (solo || online); // is there a second door to draw? if (ready) h += MODAL_GAP + 16 + fxLines.length * 12 + MODAL_FOOT + MODAL_BTN + (stack && twoUp ? MODAL_BTN + 8 : 0); h += pad; @@ -3151,7 +3157,8 @@ export function render() { ctx.font = F(7, 0); shadowText('NEXT', inX, y + 6, UI.dim); ctx.font = F(9, 700); - shadowText(promo ? 'THE PROMOTION' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title, + shadowText(promo ? 'THE PROMOTION' : online ? 'ANOTHER DUEL' + : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title, inX + 34, y + 6, UI.warm); y += 16; ctx.font = F(8, 0); @@ -3159,9 +3166,8 @@ export function render() { y += fxLines.length * 12 + MODAL_FOOT; // one button when the ceremony owns the tap, two when there is a real choice: the fixture // (the canonical path) and the way back to what you were just doing - // NETPLAY ends on one door only: the other phone's socket is still being torn down, and - // "enter the draw" would deal a solo cup into a live session. - const solo = !promo && !G.net; + // NETPLAY never offers the cup — "enter the draw" would deal a solo tournament into a live + // session. Its own two doors are the rematch and the way out (see `online` above). // THE SECOND DOOR NAMES ITS MODE (2026-08-31, Oliver: "auf dem gleichen screen steht dann // PLAY AGAIN — wo ist der unterschied?"). Beside ENTER THE CUP an unqualified "again" is // an instruction without a subject: both read as "start something", and which something @@ -3171,13 +3177,15 @@ export function render() { : G.match && G.match.train ? 'TRAIN AGAIN' : G.match ? 'CLIMB AGAIN' : 'GORE AGAIN'; const second = solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE'] - : ['again', againLabel]) : null; + : ['again', againLabel]) + : online ? ['club', 'THE CLUBHOUSE'] : null; const bw = second && !stack ? Math.round((inner - 8) / 2) : inner; // "THE DRAW" was tennis for the field of entrants — and nothing is drawn any more since the // cup went deterministic (2026-08-29). The button names what it opens instead. - const label = promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE CUP' : 'THE CLUBHOUSE'; + const label = promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE CUP' + : online ? 'REMATCH' : 'THE CLUBHOUSE'; drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true); - endBtns.push({ id: solo ? 'next' : 'club', x: inX, y, w: bw, h: MODAL_BTN }); + endBtns.push({ id: solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN }); if (second) { const sx = stack ? inX : inX + bw + 8, sy = stack ? y + MODAL_BTN + 8 : y; drawButton(sx, sy, bw, MODAL_BTN, second[1], UI.warm, false); diff --git a/test.js b/test.js index 1931724..6d77933 100644 --- a/test.js +++ b/test.js @@ -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;