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:
1 parent
cba2fb8a8b
commit
c1f7268a04
9 files changed
+412
-37
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user