The body plays the wounds it wears, and the flick finally has a teacher

Score-is-the-body was a picture: the opponent lost an arm and swung exactly as
far, lost its head and missed exactly as often. Two dials now read the same
racketStage the SKINS are picked with, so what you watch it lose is what it
loses — deliberately small (6% reach, 17.5% whiff per stage), texture under the
ladder rather than a second difficulty curve, with a test on that ceiling. An
unhurt body still returns exactly 1 and 1, and the whole thing is gated on
G.match: the gore duel's racket zombie is untouched.

And the placement flick, the mode's one act of skill, was taught nowhere on
touch — the placement ring is a receipt, not a lesson, and you only get one if
you already knew to swipe. The ghost hand teaches it now, in the same register
as PULL DOWN and TAP AS IT ARRIVES, riding the failure it answers: a run of
returns that left with no aim on them. The tap lesson still wins when both
qualify — placement is the wrong thing to teach someone who cannot reach the
ball.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-17 08:21:18 +02:00
1 parent 0c90337f2d
commit c613b6ceba
7 files changed
+147 -21

No files matched your search

+48 -2
View File
@@ -84,7 +84,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, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = 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
@@ -94,7 +94,7 @@ const { zombies, spawnZombie, pellets, parts } = 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 cfg = await import('./src/config.js');
const { TUNE, bossArt, COURT, racketStage } = cfg;
const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js');
const { read: readStats, check: statsCheck } = statsMod;
@@ -603,6 +603,52 @@ test('a match ball never wounds the body: the score is the only way through', ()
assert.equal(zb.hp, zb.hpMax, 'and unwounded — "killing by smashing" is out of the match');
});
test('THE WOUNDS PLAY: a wounded opponent covers less and whiffs more', () => {
startMatch();
const zb = oppOf();
const w = f => matchWound({ hp: zb.hpMax * f, hpMax: zb.hpMax });
assert.deepEqual(w(1), { reach: 1, flub: 1 }, 'an unhurt body plays exactly as it always did');
const arm = w(0.5), head = w(0.25); // one point in is still stage 0 — a quarter is 0.75
assert.ok(head.reach < arm.reach && arm.reach < 1, 'reach falls with every stage it loses');
assert.ok(head.flub > arm.flub && arm.flub > 1, '...and the whiff rate climbs with it');
assert.ok(head.reach > 0.85 && head.flub < 1.5,
'and it stays TEXTURE under the ladder, not a second difficulty curve: ' + JSON.stringify(head));
// ...and it is wired to the racket, not just to the table: a gap the whole body covers and the
// headless one cannot. ONE frame, so its own chase (~0.06 m) can never close the 0.19 m gap.
const swingsAt = (hp, d) => {
zb.hp = hp; zb.swingCool = 0; zb.swingT = -1;
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.pow = 0;
ball.x = zb.x + d; ball.z = zb.z; ball.y = 1.0;
ball.vx = 0; ball.vy = 0; ball.vz = -1; // incoming, or racketAI ignores it entirely
update(DT);
return zb.swingCool > 0; // a whiff arms the cooldown too — this is REACH
};
const gap = TUNE.aiReach * (1 + head.reach) / 2; // between the two reaches
assert.ok(swingsAt(zb.hpMax, gap), 'the whole body reaches ' + gap.toFixed(2) + ' m');
assert.ok(!swingsAt(1, gap), '...and the headless one does not');
});
test('the FLICK LESSON arms on a run of aimless returns and dies on an aimed one', () => {
assert.equal(flatCount(0, { ax: 0, az: 0 }), 1, 'a flat return is evidence');
assert.equal(flatCount(3, { ax: 0, az: -1 }), 4, '...and so is a depth-only one: this teaches WIDTH');
assert.equal(flatCount(9, { ax: -1, az: 0 }), 0, 'one aimed return and the lesson is over');
assert.equal(flatCount(9, null), 10, 'no aim at all is the flattest shot there is');
assert.ok(TUNE.flickHintAfter > 2, 'hitting flat is legal — it takes a RUN of them to nag');
// the wiring: a real match return with nothing on it counts itself
startMatch();
const zb = oppOf();
G.interlude.introT = 0; // the umpire has finished announcing — the tap is a swing now
G.flatStreak = 0; G.phase = 'rally';
P.x = 0; P.z = 23; P.swingT = -1;
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
assert.equal(ball.lastHit, 'P', 'he got a racket on it');
assert.equal(G.flatStreak, 1, 'and the flat return was counted');
assert.ok(zombies.includes(zb), '(the body is untouched by any of this)');
});
test('THE DEATH LOOP: a fault before a lost point must not arm second-serve forever', () => {
// The match bypasses tennis's pointTo — the only thing that ever reset G.serveNum — so the first
// fault of the match made EVERY later missed serve a double fault: a point, blood, and repeat