The lob is the flick you already have, and a game stops being a death

Three from Oliver.

THE LOB. He picked option (c) from the written comparison — "no additional
gesture to learn" — and corrected the premise: the zombie does come to the
net (racketAI chases to the far service line, the classic CPU to z 9.5). So
the forward flick keeps its one meaning, "put it deep behind him", and the
game picks the trajectory that actually does that: a smash while he is back,
over his head once he is in. oppAtNet() is the whole switch, and it reads the
BODY, not O — in the match O is the invisible tennis body and only moves
between points, so it would have answered "on his mark" all rally.

lobArm 7.0 m is measured, not taste: a lob peaking at lobApex passes a man 7 m
from the net at almost exactly CONTACT.maxY, so the shot arms exactly as far
out as it can still clear him. The pace is derived from the height, so the same
arc comes off the baseline and off mid-court, and SHOT_DEPTH.lob is deeper than
deep — at the ordinary deep target the ball passed 2.05 m over him at the edge
of the band, which is inside his reach.

PACE, SEEN FROM THE STRIKER. "the opponent sees the ball really fast - the
player barely sees the speed increase." Measured: the ball is the same ball on
both phones, the camera is not — it compresses your own ball into the distance
and expands theirs as it arrives. Pace untouched; three channels added on the
striker's side: the ghost streak now reads pace (3-8 ghosts, was 3 at every
speed), the crack is pitched by it, and a fast ball kicks the camera. The
serve's own ladder is 49.2 plain / 50.8-52.4 hard, because serveArc's loft
margin takes 10% of the +9 back — widening that is his call, and PROJECT.md
records the knob and the risk.

THE DEATH MATCH. "each player has 8 blood points but the player explodes for
the winner already at 0:40." Two clocks: a game is four points, a life is
eight. A won game is only a game now (flash, serve, heal); the burst moved to
the death itself, on the dead message, where both phones agree. The summary
waits 1.4 s for the gore, because update() freezes the world on G.done and
would have hung a still frame of an explosion behind the trophy screen. And
bleedO tracks THEIR blood off the pos stream instead of our points-this-game,
which reset every game and ignored the heal.

4 new tests, 113 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 12:58:48 +02:00
1 parent 6e07dc74bb
commit 4f4534e272
9 files changed
+355 -38

No files matched your search

+114 -14
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, inCourt } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt, oppAtNet } = 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
@@ -99,7 +99,7 @@ const { zombies, spawnZombie, pellets, parts, explode } = await import('./src/zo
const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const cfg = await import('./src/config.js');
const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg;
const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js');
const { hapticTest } = await import('./src/haptics.js');
@@ -735,6 +735,91 @@ test('a receiver stands BEHIND his baseline, on both sides of the net', () => {
assert.ok(P.z > COURT.L, '...with the server behind his own line');
});
test('THE LOB: the forward flick goes OVER him once he has come in', () => {
// Oliver, 2026-08-22: "when you're close to the net in a duel there should be a gesture allowing
// to make a ball flying high from below." Option (c): no new gesture — the flick that already
// means "deep" picks the trajectory that actually puts the ball deep, which with a man at the net
// is over his head. The contract: unchanged while he is back, unhittable while he is in.
const shot = (oz, swipe) => {
const rnd = Math.random; Math.random = () => 0.5; // no scatter: the NOMINAL arc
startMatch(); G.interlude.introT = 0; G.phase = 'rally';
const zb = oppOf(); zb.x = 0; zb.z = oz;
P.x = 0; P.z = 23; P.swingT = -1;
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = 0; ball.z = 22.6; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1; ball.g = TUNE.gravity;
G.swipe = swipe; keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
G.swipe = null; Math.random = rnd;
assert.equal(ball.lastHit, 'P', 'setup: he got a racket on it');
let y = ball.y, z = ball.z, vy = ball.vy, apex = y, over = null;
const g = ball.g, dt = 1 / 240;
for (let i = 0; i < 4000 && y > 0; i++) {
const z0 = z; vy -= g * dt; y += vy * dt; z += ball.vz * dt;
apex = Math.max(apex, y);
if (over === null && z0 > zb.z && z <= zb.z) over = y; // height as it passes him
}
return { s: Math.hypot(ball.vx, ball.vz), apex, over, land: z };
};
const UP = { ax: 0, az: -1, fx: 0, fy: -1, mag: 90 };
const back = shot(1.5, UP);
assert.ok(back.apex < 2.5, 'he is on his baseline: the forward flick is the DRIVE it always was, apex ' + back.apex.toFixed(1));
assert.ok(back.s > 19, '...at drive pace: ' + back.s.toFixed(1) + ' m/s');
// ...and once he steps in, the SAME gesture lifts it over him. Two positions: the far service
// line (as far back as the racket zombie's chase ever brings it) and the edge of the arm band.
for (const oz of [9.5, 5.5, COURT.netZ - TUNE.lobArm + 0.05]) {
const l = shot(oz, UP);
assert.ok(Math.abs(l.apex - TUNE.lobApex) < 0.2, 'it peaks at lobApex from anywhere: ' + l.apex.toFixed(1));
assert.ok(l.over > CONTACT.maxY, 'it passes him at ' + l.over.toFixed(2) + 'm, over his ' + CONTACT.maxY + 'm reach (z ' + oz + ')');
assert.ok(l.land > 0.3 && l.land < COURT.netZ, '...and comes down IN, at z ' + l.land.toFixed(1));
assert.ok(l.s < back.s * 0.6, '...off a much slower ball: ' + l.s.toFixed(1) + ' m/s');
}
// the sideways pass is untouched — only the FORWARD flick changes meaning
const pass = shot(5.5, { ax: 1, az: 0, fx: 1, fy: 0, mag: 90 });
assert.ok(pass.apex < 2.5 && pass.s > 19, 'a sideways flick with him at the net is still a drive');
});
test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => {
// Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the
// opponent sees the ball really fast — the player barely sees the speed increase." The ball IS
// the same ball on both phones (net.js copies the velocities verbatim); what differs is the
// camera, which compresses your own outgoing ball into the distance and expands theirs as it
// comes at you. So the striker gets the pace in three channels he cannot miss: the ghost streak's
// LENGTH (render), the crack's pitch, and this.
restartRun(); G.classic = true;
score.server = 'P'; setupPoint();
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
G.shake = null;
hitServe(P, 1, null, true); // a flush serve: ~49 m/s
assert.ok(G.shake && G.shake.mag > 0, 'a 49 m/s serve kicks: mag ' + (G.shake && G.shake.mag.toFixed(1)));
const hard = G.shake.mag;
// ...and a slow ball does not. Same call, no timing on it.
score.server = 'P'; setupPoint();
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
G.shake = null;
hitServe(P, 0.05, null, false); // a badly mistimed one: ~24 m/s
assert.ok(Math.hypot(ball.vx, ball.vz) < 30, 'setup: that one left slowly');
assert.equal(G.shake, null, 'a slow serve does not kick the camera');
assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')');
});
test('THE LOB arms off the BODY, not off the invisible tennis opponent', () => {
// In the match O is the body the serve machinery is driven off and it only moves between points;
// the opponent you actually play is the racket zombie. Asking O would answer "on his mark" all
// rally, so the lob would arm at the wrong times in the one mode Oliver plays most.
startMatch();
const zb = oppOf();
O.z = 10.5; // O parked at the net, the body back on its mark
zb.z = 1.5;
assert.ok(!oppAtNet(), 'the BODY is back, so nothing is armed');
zb.z = 6.0;
assert.ok(oppAtNet(), '...and the body coming in arms it');
// classic tennis has no body: there O IS the opponent
restartRun(); G.classic = true;
O.z = 1.5; assert.ok(!oppAtNet(), 'classic: O back, not armed');
O.z = 9.5; assert.ok(oppAtNet(), 'classic: O in, armed');
});
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.
@@ -1725,25 +1810,35 @@ test('netplay death match: the host\'s pick rebuilds the court on pairing — fr
assert.equal(G.modal, null, 'no popup may eat the first remote serve');
});
test('netplay death match: their lost points read as BLOOD, and the game bursts the body', () => {
test('netplay death match: THE BURST IS THE DEATH — a won game is only a game', () => {
// Oliver, 2026-08-22: "each player has 8 blood points but the player explodes for the winner
// already at 0:40 — the winner sees the zombie explode but the player on the other side is not
// dead yet. this needs to be aligned." Two clocks: a game is four points, a life is eight.
freshNetDeath('A');
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp');
assert.equal(score.pts[0], 1, 'the point banks');
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
const blood = G.hp;
netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me
assert.equal(G.hp, blood - 1, 'their point draws my blood');
score.pts = [3, 0]; // game point against them: the flow at full ramp
const before = parts.length;
for (let i = 0; i < 30; i++) update(DT);
assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)');
// THE FLOW READS THEIR BLOOD, off the position stream — not our points-this-game
const onHim = () => parts.filter(q => Math.abs(q.x - O.x) < 1 && Math.abs(q.z - O.z) < 1).length;
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: G.hpMax }); // (their z 2 mirrors to O.z 21.77... )
O.x = 0; O.z = 2; // ...so park him by hand: this is bleedO, not the lerp
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
assert.equal(onHim(), 0, 'a friend at full blood does not bleed, whatever our score says');
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: 1 });
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
assert.ok(onHim() > 0, '...and one a point from death bleeds hard');
score.pts = [3, 0];
netMsg({ t: 'pt', win: 'you' }); // the game-winning point
assert.equal(score.games[0], 1, 'game banked');
assert.deepEqual(score.pts, [0, 0], 'the blood ramp resets with the body');
assert.equal(G.hideO, true, 'the burst took the body');
for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT);
assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again');
assert.equal(G.hideO, false, 'A GAME IS NOT A DEATH: the friend is still standing');
assert.ok(!G.done, '...and the match is not over');
for (let i = 0; i < 60 * 6; i++) update(DT);
assert.equal(G.hideO, false, 'still standing a beat later — no burst, no walk-on');
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
assert.ok(G.phase === 'pretoss' || G.phase === 'over', 'and the court is playable again');
});
test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => {
@@ -1765,9 +1860,14 @@ test('netplay death match: bleeding out is said on the wire, and the other phone
assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud');
freshNetDeath('A'); // ...and the winner's phone, receiving it
score.games[0] = 3;
const before = parts.length;
netMsg({ t: 'dead' });
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending');
assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body');
assert.equal(G.hideO, true, 'THE BURST IS THE DEATH: their blood ran out, so the body comes apart');
assert.ok(parts.length > before, '...and the gore is real');
assert.ok(!G.done, 'the summary waits — a frozen explosion behind a trophy screen is the bug');
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending, after the burst');
assert.equal(G.match.felled, 3, 'felled = the games won on the way');
});
test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {