The flick stops losing the point, and the corpse falls and serves

THE FLICK. "when returning a ball with a spin it seems too sensitive, 8/10
times the ball lands out." Arithmetic, not feel. The singles line is 4.115 m;
an aimed shot targeted a flat ax*3.3 and THEN added err() on top — up to
+-2.4 m at quality 0 — so a flicked return landed as wide as 5.7 m, and the
only clamp in the path sat at +-5, outside the court. An UNAIMED return
targets 0 + the same scatter and is therefore always in: aiming was the thing
losing the point, which is backwards for the mode's one act of skill.

TUNE.flickReach scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line — worst case 1.49+2.4 = 3.89, and
a perfectly timed flick still gets the full 3.3 with nothing left to scatter.
The test asserts that sum across the whole quality range rather than sampling
rallies. Not named `aimReach`: that key already exists further down TUNE (the
slingshot's 22 m aim ray) and the duplicate silently won until a test caught it.

The placement ring carries `q` now and is the flick's receipt — tight and hot
when your timing earned the full width, wide and dim when you barely got a
racket on it. Without it the rule is invisible: the shot just goes less wide.

THE FALL (handoff item 4). A point that takes a limb knocks the body down and
it drags itself up short of it. The three collapse frames wear the stage it
STILL WAS and the rise wears the stage it has BECOME — that order is the whole
beat, and it is what turns a popping wound stage into something the point did.
Own clock beside the horde's warning, for the reason this file keeps relearning
(a beat that moves the phase under a latch strands it); the 'over' beat it sets
outlives the fall so it cannot expire into setupPoint() mid-collapse. Only
fires when the stage actually moved — a quarter off a full pool is 0.75, so
point one has no limb to explain and a second of collapse for nothing is a beat
the player waits out.

THE OPPONENT SERVES (handoff item 7), and it was mostly switched off rather
than missing. setupPoint already placed a non-player server behind its own
baseline and armed aiServeT; update() already tossed and struck for O. Both
were gated on `G.mode === 'tennis'` and the match runs in splatter under a
permanent interlude — one condition. Genuinely new: service alternates at each
game boundary (the match calls advancePoint directly and the flip lives in
tennis's pointTo, which it bypasses — the same shape as the G.serveNum bug),
the zombie takes O's mark so picture and ballistics agree on where it stands,
and the serve poses are named onto it because O is invisible here.

racketStage() moves to config: drawZombie and the fall must not answer the
same question twice, which is how the boss's skins and its blood drifted apart.

Docs: PROJECT.md gains THE ZOMBIE FAMILY PIPELINE — the measured API contract
(4K is a parameter and the default is 1K; n is 1 so "return both" is not a
thing; aspect is an enum so pad to it), why magenta stays despite green being
the documented key, and the two things _zsheet.py must do globally.

Verified in the browser: brute in the match, z_brfall2 -> z_brw1rise on a
wounding point, and bounce -> toss -> serve off the opponent's own baseline.
58 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 19:08:02 +02:00
1 parent cba2fb8a8b
commit c1f7268a04
9 files changed
+412 -37

No files matched your search

+82 -2
View File
@@ -83,14 +83,14 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.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; };
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const { TUNE, bossArt, COURT } = await import('./src/config.js');
const { TUNE, bossArt, COURT, racketStage } = await import('./src/config.js');
const { read: readStats, check: statsCheck } = await import('./src/stats.js');
const DT = 1 / 60;
@@ -642,6 +642,86 @@ test('the first opponent cannot paint lines: its returns are width-clamped', ()
assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung');
});
test('an AIMED return cannot be sent out by its own aim, at any timing', () => {
// The bug this pins: an aimed shot targeted a flat 3.3 m and THEN took err() on top — +-aimError
// at quality 0 — so a flicked return landed as wide as 5.7 m against a 4.115 m singles line and
// went out about eight times in ten, while an unaimed one (0 + the same scatter) was always in.
// Flicking, the mode's one act of skill, was what lost the point. Reach scales with the same
// quality the scatter shrinks with, so the two can never sum past the line — that sum is the
// whole rule and it is arithmetic, so assert the arithmetic rather than a sampled rally.
const line = COURT.W / 2, aimW = 3.3; // doHit's far-court aim width
for (let q = 0; q <= 1.0001; q += 0.05) {
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
const worst = aimW * reach + TUNE.aimError * (1 - q);
assert.ok(worst <= line,
`aimed reach + worst scatter must stay inside the line at quality ${q.toFixed(2)}: ` +
`${worst.toFixed(2)} vs ${line.toFixed(3)}`);
}
// ...and it must still be worth flicking: perfect timing keeps the full width.
assert.ok(aimW * (TUNE.flickReach + (1 - TUNE.flickReach)) > line * 0.75,
'a perfectly timed flick still reaches the corner');
});
test('a wounding point knocks the body down and it gets up MINUS the limb', () => {
// The beat exists because the wound stage used to POP between points — an arm stopped being there
// and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears
// the stage it has BECOME, and the point is handed back only when it is standing again.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
const zb = zombies.find(z => z.variant === 'r');
assert.equal(racketStage(zb), 0, 'setup: opponent starts unhurt');
// A quarter off a full pool is 0.75 — still stage 0, so point ONE has no limb to explain. The
// stage crosses on the second point (0.5) and the third (0.25); that progression is the beat.
resolveInterlude('P');
assert.equal(G.matchFall, null, 'point one takes a quarter but crosses nothing');
resolveInterlude('P'); // ...this one takes the arm
assert.ok(G.matchFall, 'a stage-crossing point starts the fall');
assert.equal(G.matchFall.from, 0, 'it collapses as the body it still was');
update(DT);
assert.match(zb.showFrame, /fall1$/, 'first frame is the buckle');
assert.ok(!/w\d/.test(zb.showFrame), 'and it is the UNWOUNDED buckle');
// ...run to the rise, which must wear the NEW stage
for (let i = 0; i < 0.75 * 60; i++) update(DT);
assert.match(zb.showFrame, /w1rise$/, 'it drags itself up as the one-armed body');
// ...and the point comes back only once it is standing
for (let i = 0; i < 0.6 * 60; i++) update(DT);
assert.equal(G.matchFall, null, 'the fall ends on its own clock');
assert.equal(zb.showFrame, null, 'and hands the sprite back to drawZombie');
assert.equal(G.serveNum, 1, 'matchReady ran: the next point is armed');
});
test('service alternates by game, and the opponent actually serves', () => {
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return
// timing is half the sport and it was never asked for.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
assert.equal(score.server, 'P', 'you open the match');
for (let pt = 0; pt < 4; pt++) { // four straight points is the game
resolveInterlude('P');
for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish
}
assert.equal(score.server, 'O', 'their serve next game');
// the opponent stands behind ITS baseline to serve, not on the receiving mark
const rz = zombies.find(z => z.variant === 'r');
assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2));
// ...and it tosses and strikes on its own, once the walk-on beat is over
let served = false;
for (let i = 0; i < 8 * 60 && !served; i++) { update(DT); if (ball.active && ball.lastHit === 'O') served = true; }
assert.ok(served, 'the opponent serves without the player touching anything');
});
test('a lost point never fells the opponent', () => {
// The fall is what a WOUND looks like. A point the player loses costs blood, not a limb, and a
// collapse there would read as the opponent being hurt by winning.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
const hp = G.hp;
resolveInterlude('O');
assert.equal(G.matchFall, null, 'their point starts no fall');
assert.equal(G.hp, hp - 1, '...it draws your blood instead');
});
test('the placement mark fades on its own clock', () => {
// The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the
// state machine owes is that a mark never lingers — a permanent ring would read as a target the