From 6e07dc74bb6f63636fc4460d782f2db3459dbd48 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 22 Aug 2026 09:40:48 +0200 Subject: [PATCH] The receiver waits behind his line, and a missed serve buys a second one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver, three notes; two of them are here. "when the opponent serves the player does not stand at the baseline - but that's how it should be." setupPoint's receiving marks sat over a metre INSIDE the court on both sides (22.5 near, 1.2 far) — where you stand to attack a second serve, not where you wait for a first one. They mirror the server's depth now, in the one line every serving mode shares. "when serving results in an out there should be a second service... the umpire should announce 2nd service and the player must get another chance." True in tennis mode since forever and absent everywhere else: the duel, the match and the death match had no service rule at all, so a serve long or into the net was judged as an ordinary ground stroke — a point, and in the match a point costs blood. The out branch of onBounce's interlude block routes a serve to fault() instead, so the second serve, the HUD line, the umpire and the double fault all come for free. Three things it dragged out with it: - fault()'s double-fault branch called pointTo — TENNIS scoring — which in an interlude banks a number and skips the mode that owns the court. It goes through resolveInterlude there now. Nothing had ever reached that line from a duel or a match, because those modes could not fault. - startDuel never reset G.serveNum: harmless while a duel could not fault, the next duel's death loop the moment it can. - the match puts the body back on its receiving mark for the second serve (placeReceiver, split out of matchReady, which resets the serve number). "Fault. Second service." is one voiceSeq — a second voice() call would be dropped, one man one microphone. New recording via the usual say/afconvert. The service BOX is still not enforced in the match, deliberately: this is strictly more forgiving than what shipped. The lob gesture is OPEN 14. 4 new tests, 110 pass. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 57 ++++++++++++++++++++++++++ assets/audio/second_service.m4a | Bin 0 -> 9880 bytes index.html | 2 +- src/beats.js | 33 +++++++++------ src/gameplay.js | 33 +++++++++++++-- src/state.js | 7 +++- test.js | 69 +++++++++++++++++++++++++++++++- 7 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 assets/audio/second_service.m4a diff --git a/PROJECT.md b/PROJECT.md index 3901042..c3668d5 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,46 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: THE SERVICE RULES ARRIVE IN THE OTHER MODES (2026-08-22) + +Three notes from Oliver, two of them fixed here; the third (the lob) is under discussion in OPEN. + +- **A RECEIVER STANDS BEHIND HIS BASELINE.** *"when the opponent serves the player does not stand at + the baseline — but that's how it should be."* `setupPoint`'s receiving marks were z 22.5 near and + 1.2 far — over a metre INSIDE the court on both sides, which is where you stand to attack a second + serve, not where you wait for a first one, and next to a server drawn behind his own line it read + as a man who had wandered in. They now mirror the server's depth (24.2 / -0.43). It is one line, so + every mode that serves through the tennis branch got it: classic, gore's opener, the match, and + netplay. The duel is unaffected by design — it takes setupPoint's splatter early-out and is always + your serve, from wherever you are standing. +- **A MISSED SERVE BUYS A SECOND ONE.** *"when serving results in an out there should be a second + service... the umpire should announce 2nd service and the player must get another chance."* True + in tennis mode since forever, and **absent from every other mode**: the duel, the match and the + death match had no service rule at all, so the first serve was judged as an ordinary ground stroke + and a serve long or into the net handed the point away — in the match that is a point AND blood, on + the very first ball of it. The out branch of `onBounce`'s interlude block now routes a serve to + `fault()` instead of `resolveInterlude`, so the second serve, the HUD's SECOND SERVE line, the + umpire and the double fault all come for free. Three things it dragged out with it: + 1. **`fault()`'s double-fault branch called `pointTo`** — TENNIS scoring — which in an interlude + would bank a number and skip the mode that owns the court (blood, the fall, the ladder). It + routes through `resolveInterlude` there now. Nothing had ever reached that line from a duel or a + match, because those modes could not fault. + 2. **`startDuel` never reset `G.serveNum`.** Harmless while a duel could not fault; the moment it + can, a fault in one duel arms second-serve in the NEXT one — the exact death loop `matchReady` + already carries a note about, one mode over. + 3. **The match puts the body back on its receiving mark for the second serve** (`placeReceiver`, + split out of `matchReady` because that call resets the serve number a fault has just spent). + Not in netplay: the called ball is left rolling there, and `matchReady` would freeze it. +- **THE BOX IS STILL NOT ENFORCED IN THE MATCH, deliberately.** Classic checks the service box; the + match's line call only ever checked the court, and a deep-but-in serve stays legal there. That is + the concession the mode was tuned around (see the "classic tennis gives double fault very often" + note in `keyPressed`) and this change is strictly more forgiving than what shipped. Enforcing it + is a one-line change if Oliver wants real service boxes in zombie tennis — ask before doing it. +- **One new recording: `second_service.m4a`** (`say -v Daniel -r 168` -> `afconvert`, the existing + pipeline). It plays as a SEQUENCE with the fault call — `voiceSeq(['fault', 'second_service'])` — + because `voice()` drops any line that would start while the umpire is still talking, so a second + `voice()` on the next line would have been silently swallowed. "Fault. Second service." + ## ZOMBIE TENNIS — the mode, end to end (folded from HANDOFF-MATCH.md, 2026-08-17) `HANDOFF-MATCH.md` was a temporary file written when a session ran out of context mid-slice. The @@ -3901,6 +3941,23 @@ Nothing here is broken; these are known gaps and unverified risks. from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave. - **boss sounds** — footfall, wind-up, throw, wound, death. Three designs in `BOSS_ART`; decide whether they share one set or get one each before recording anything. +14. **THE LOB ("lupfen") — asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.** Oliver: + *"when you're close to the net in a duel with another player there should be a gesture allowing + to make a ball flying high from below... I don't know how to implement this gesture wise — this + likely needs to be discussed."* The shot itself is nearly free: `trySwing` already solves every + return through `solveShot` with a target depth from `SHOT_DEPTH` (up = deep, none = mid, down = + short), so a lob is a deep target with a much slower `s` and the net-clearance loop doing the + rest — the same maths, different two numbers. **The GESTURE is the whole question**, and every + obvious answer is already spoken for: the flick is the rally aim (up = deep), the pull is the + slingshot, a hold is nothing yet but is also invisible. Candidates to put to him, cheapest first: + (a) the flick that is already there, but STEEPER THAN THE DEEP ONE — the up-flick keeps meaning + "deep" and a near-vertical up-flick means "over him"; costs one threshold and no new input; + (b) the same flick, but only ARMED while the opponent is inside his own service box (the shot + only exists as an answer to a man at the net, so context can select it instead of the gesture); + (c) a two-finger tap or an up-SWIPE from below the ball, both of which are new input surfaces. + Whatever wins needs the receiving side to be answerable too, or a lob at the net is an ace: the + zombie's chase reads the BOUNCE (`aiRead`), so a high slow ball gives it time — probably fine, + but it must be measured, not assumed. --- diff --git a/assets/audio/second_service.m4a b/assets/audio/second_service.m4a new file mode 100644 index 0000000000000000000000000000000000000000..4863581be359d2e19af6e7888b2cdef205f106c6 GIT binary patch literal 9880 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 - + diff --git a/src/beats.js b/src/beats.js index f74d67e..df9acdd 100644 --- a/src/beats.js +++ b/src/beats.js @@ -71,6 +71,10 @@ export function startDuel() { G.weapon = 0; // tennis rules for the duel: plain balls, no tins G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order. G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces + G.serveNum = 1; // ...on a FIRST serve. A duel now faults (gameplay's onBounce), and + // nothing else resets it here — a fault in the last duel would have + // armed second-serve in the next one, which is the death loop this + // file already carries a note about, one mode over. G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration) ball.active = false; setupPoint(); zombieDuck(true); // fade the zombie music out for the 1:1 duel @@ -420,18 +424,23 @@ export function matchReady() { // exported for net.js: a paired death match re-p // when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve // machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for // the mark, which is what stops the picture and the ballistics disagreeing about where it stands. - if (rz) { - // WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from - // there he bisects nothing: the T is one step away and the corner of the box is four. Measured - // headless, a flush serve with full sway into that corner was returned 0 times out of 20 at - // EVERY rung of the ladder — Oliver: "the first ten zombies can be killed the moment you - // serve". He now stands out on the bisector of the two serves he has to cover, which is what a - // real receiver does, and O takes the same mark so the picture and the ballistics still agree. - if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX; - rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0; - rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; - rz.showFrame = null; rz.serveHold = 0; - } + if (rz) placeReceiver(rz); +} + +// WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from there he +// bisects nothing: the T is one step away and the corner of the box is four. Measured headless, a +// flush serve with full sway into that corner was returned 0 times out of 20 at EVERY rung of the +// ladder — Oliver: "the first ten zombies can be killed the moment you serve". He now stands out on +// the bisector of the two serves he has to cover, which is what a real receiver does, and O takes +// the same mark so the picture and the ballistics still agree. +// Split out of matchReady because a SECOND SERVE needs the same mark without matchReady's rest: +// that call resets G.serveNum, which is the one thing a fault has just spent (see fault()). +export function placeReceiver(rz = zombies.find(z => z.variant === 'r')) { + if (!rz) return; + if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX; + rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0; + rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; + rz.showFrame = null; rz.serveHold = 0; } // ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB. diff --git a/src/gameplay.js b/src/gameplay.js index cc84eb9..0210928 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -2,14 +2,14 @@ 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, matchWound, CUP_ROUNDS } 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 { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { haptic } from './haptics.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateDrill } from './drill.js'; import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; -import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing +import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal // ------------------------------------------------------------- ballistics ---- @@ -1048,8 +1048,22 @@ export function fault() { // exported for net.js: the receiver's fault call re-a if (G.serveNum === 1) { if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double // fault below goes out through pointTo as the point it is - G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); + // "Fault. Second service." — the second half is the whole point of the call: a lone "fault" + // never said that the player still had a serve coming (Oliver, 2026-08-22). voiceSeq, not two + // voice() calls: the umpire is one man with one microphone and the second line would be DROPPED. + G.serveNum = 2; voiceSeq(['fault', 'second_service']); G.phase = 'over'; G.overT = 1.4; sendBoy(); + // ...and in a MATCH the body goes back on its receiving mark for the second serve, exactly as + // matchReady puts it there for a first one — it is standing wherever it chased the fault to. + // Not in netplay: the called ball is left rolling there (net.js), and matchReady's ball.active + // = false would freeze it mid-roll on the far phone. + if (G.match && !G.net) placeReceiver(); } + // A DOUBLE FAULT IS A POINT, AND A POINT IS SCORED BY THE MODE THAT OWNS THE COURT. pointTo is + // TENNIS scoring; the duel and the match settle points through resolveInterlude (blood, the fall, + // the ladder) and a raw pointTo there would bank a number and skip all of it — the same seam the + // interlude branch of onBounce is written around. Nothing reached this line from an interlude + // before, because those modes had no fault in them at all. + else if (G.interlude) { voice('double_fault'); resolveInterlude(other(score.server)); } else pointTo(other(score.server), 'double_fault'); } @@ -1147,7 +1161,18 @@ function onBounce() { if (G.net && ball.lastHit === 'P') return; if (ball.bounces === 1) { const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; - if (!inCourt(ball.x, ball.z) || !okSide) { endFlight(); resolveInterlude(other(ball.lastHit)); voice('out'); } + // A MISSED SERVE BUYS A SECOND ONE, IT DOES NOT LOSE THE POINT (Oliver, 2026-08-22: "when + // serving results in an out there should be a second service"). These modes had no service + // rule at all: the first serve was judged as an ordinary ground stroke, so a serve into the + // net or over the baseline handed the point away on the spot — in the match, that is a point + // and blood for a first serve. It goes through the same fault() the tennis modes use, so the + // second serve, the umpire's call and the double fault all come for free. + // The BOX is deliberately still not enforced here (see the note in hitServe): a deep-but-in + // serve stays legal in the match, which is the concession that mode was tuned around. + if (!inCourt(ball.x, ball.z) || !okSide) { + endFlight(); + if (G.wasServe) fault(); else { resolveInterlude(other(ball.lastHit)); voice('out'); } + } // THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know // 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 diff --git a/src/state.js b/src/state.js index 3ec3f26..1f2b2bb 100644 --- a/src/state.js +++ b/src/state.js @@ -217,7 +217,12 @@ export function setupPoint() { const sx = deuceSide() ? 1.3 : -1.3; sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net sv.z = sv.side === 'P' ? 24.3 : -0.5; - rc.x = -sv.x; rc.z = rc.side === 'P' ? 22.5 : HOME.O.z - 1.3; + // A RECEIVER STANDS BEHIND HIS BASELINE (Oliver, 2026-08-22: "when the opponent serves the + // player does not stand at the baseline — but that's how it should be"). Both marks used to sit + // over a metre INSIDE the court (22.5 near, 1.2 far), which is where you stand to return a second + // serve, not a first one — and next to a server drawn behind his own line it read as a man who + // had wandered in. They now mirror the server's depth: 0.4-0.5 m behind the line on both sides. + rc.x = -sv.x; rc.z = rc.side === 'P' ? 24.2 : -0.43; P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1; P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored G.aiServeT = score.server === 'O' ? 1.4 : 0; diff --git a/test.js b/test.js index 27a0658..7aba9ef 100644 --- a/test.js +++ b/test.js @@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; -const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer } = await import('./src/gameplay.js'); +const { update, forceZombies, startGore, splatPoint, restartRun, 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'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -668,6 +668,73 @@ test('THE DEATH LOOP: a fault before a lost point must not arm second-serve fore assert.equal(G.serveNum, 1, '...whoever won it'); }); +test('SECOND SERVICE: a missed serve in the match faults instead of losing the point', () => { + // Oliver, 2026-08-22: "when serving results in an out there should be a second service... the + // umpire should announce 2nd service and the player must get another chance." The duel and the + // match had no service rule at all — the first serve was judged as an ordinary ground stroke, so + // a serve long or into the net was a point, and in the match a point costs blood. + startMatch(); + for (let i = 0; i < 2.6 * 60; i++) update(DT); // the walk-on beat, then his serve + const zb = oppOf(); + const pts = score.pts.slice(), hp = G.hp; + zb.x = 3.9; zb.z = 5.5; // it chased the fault out to the wing + const serveOut = () => { // a serve that lands past the far baseline + G.phase = 'rally'; G.wasServe = true; + ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0; + ball.x = 0.4; ball.z = 4; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -20; ball.g = TUNE.gravity; + for (let i = 0; i < 60 && ball.active; i++) update(DT); + }; + serveOut(); + assert.equal(G.serveNum, 2, 'the first miss buys a second serve'); + assert.deepEqual(score.pts, pts, '...and costs no point'); + assert.equal(G.hp, hp, '...and no blood'); + assert.equal(G.matchFall, null, 'nobody falls over a fault'); + assert.equal(zb.z, 1.0, 'the body is back on its receiving mark for the second serve'); + assert.equal(zb.x, O.x, '...on the same mark the invisible tennis body serves against'); + serveOut(); // the double fault + assert.equal(score.pts[1], pts[1] + 1, 'two misses is the point, theirs'); + assert.equal(G.hp, hp - 1, '...and the match scores it in blood, like every other lost point'); + assert.equal(G.serveNum, 1, 'the next point starts on a first serve'); +}); + +test('SECOND SERVICE: a serve that lands IN is still played, fault or not', () => { + // The guard is on the OUT branch only — the mode's other rules (the early-decided winner, the + // second bounce) must not learn about serves. + startMatch(); + for (let i = 0; i < 2.6 * 60; i++) update(DT); + const pts = score.pts.slice(); + G.phase = 'rally'; G.wasServe = true; + ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0; + ball.x = 0.4; ball.z = 8; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -8; ball.g = TUNE.gravity; + for (let i = 0; i < 30 && ball.bounces < 1; i++) update(DT); + assert.equal(ball.bounces, 1, 'setup: it bounced'); + assert.ok(inCourt(ball.x, ball.z), '...in court'); + assert.equal(G.serveNum, 1, 'a legal serve is no fault'); + assert.deepEqual(score.pts, pts, 'and no point either — it is a rally now'); +}); + +test('a duel opens on a FIRST serve, whatever the last one ended on', () => { + // Same shape as the match's death loop, one mode over: nothing in startDuel reset G.serveNum, and + // now that a duel can fault, a fault in one duel would have armed second-serve in the next. + restartRun(); + G.mode = 'splatter'; + G.serveNum = 2; + startDuel(); + assert.equal(G.serveNum, 1, 'the duel is served for the first time'); +}); + +test('a receiver stands BEHIND his baseline, on both sides of the net', () => { + // Oliver: "when the opponent serves the player does not stand at the baseline — but that's how it + // should be." Both marks used to sit over a metre inside the court. + restartRun(); + score.server = 'O'; setupPoint(); + assert.ok(P.z > COURT.L, 'receiving, the player waits behind his own baseline: z=' + P.z); + assert.ok(O.z < -0.2, '...and the server is behind his: z=' + O.z); + score.server = 'P'; setupPoint(); + assert.ok(O.z < 0, 'and mirrored, the far receiver is behind the far baseline: z=' + O.z); + assert.ok(P.z > COURT.L, '...with the server behind his own line'); +}); + test('the match brings its own legs: auto-run works even where autoAssist is 0', () => { // The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a // racket — the flight recorder showed a swing in every lost point and an x that never moved.