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;