The receiver waits behind his line, and a missed serve buys a second one

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 09:40:48 +02:00
1 parent 3d7e66449a
commit 6e07dc74bb
7 files changed
+182 -19

No files matched your search

+68 -1
View File
@@ -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.