From f67411e330fd031467fc291989a481209cb32796 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 22:41:10 +0200 Subject: [PATCH] The opponent shows up, and the death loop dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "still very weak and slow opponent. it also spawns at places where it is impossible to react to my serve. so i win like always. in the cases i dont win there is a bug. when trying to serve after i got hurt i get hurt again and again until i die". The death loop was G.serveNum. Tennis's pointTo is the only thing that ever reset it, and the match bypasses pointTo entirely — so the first fault of the match armed "second serve" permanently, and every missed serve after it was a double fault, a point, and blood, repeating to death. Exactly the spiral a rattled player serves into. matchReady(), the match's own point boundary, resets it now; a regression pins it. The receiver starts every point on its mark: matchReady drops it onto the exact receiver position the tennis branch computes for O, deuce/ad mirrored, instead of a random spawn x or wherever the last rally left it. An opponent that cannot reach the serve is not weak, it is absent. And the base opponent is a player now: matchSpeedBase 1.4 (the duel's 2.8m/s lumber was tuned for one sudden-death point), matchFlub 0.08 (0.2 gives away a fifth of all returns). Measured with the same autopilot that previously won seven straight games: it now loses the match. Possibly too hot — the two dials are named, Oliver's thumb decides. 40 tests, 15 seeds clean. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 19 +++++++++++++++++++ index.html | 2 +- src/beats.js | 28 ++++++++++++++++++++++------ src/config.js | 9 +++++++-- src/gameplay.js | 10 +++++++--- test.js | 29 +++++++++++++++++++++++++++-- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index ffefc17..153a180 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -870,6 +870,25 @@ after the first serve does not happen from behind the groundline"*. tennis branch. Serving from mid-court where the last rally left you read as a bug because it was one. Verified: second serve from z 24.3, x mirrored to the ad side. +**FIFTEENTH PASS — the opponent shows up, and the death loop dies.** Oliver: *"still very weak and +slow opponent. it also spawns at places where it is impossible to react to my serve. so i win like +always. in the cases i dont win there is a bug. when trying to serve after i got hurt i get hurt +again and again until i die"*. +- **THE DEATH LOOP was `G.serveNum`.** Tennis's `pointTo` is the only thing that ever reset it — and + the match bypasses pointTo entirely. So the first fault of the match armed "second serve" + PERMANENTLY: every missed serve after it was a double fault -> a point -> blood -> repeat to + death, which is exactly the spiral a just-hurt, rattled player serves into. `matchReady()` (the + match's own point boundary) resets it now, and a regression pins it. +- **The receiver starts every point ON ITS MARK.** It was spawning at a random x and standing + wherever the last rally abandoned it — an opponent that cannot reach the serve is not weak, it is + absent, and every ace against it was hollow. `matchReady` drops it onto the exact receiver + position the tennis branch computes for O (deuce/ad mirrored with the server). +- **The base opponent is a player now**: `matchSpeedBase` 1.4 (the duel's 2.8 m/s lumber was tuned + for ONE sudden-death point, not a game) and `matchFlub` 0.08 (the duel's 0.2 gives away a fifth of + all returns — an opponent that beats itself). Measured with the same autopilot that previously won + seven straight games: **it now loses the match** (2-3, then blooded out in game two). That may + even be too hot — `matchSpeedBase`/`matchFlub` are the two dials, and Oliver's thumb is the judge. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index f837e08..974b25e 100644 --- a/index.html +++ b/index.html @@ -400,6 +400,6 @@ - + diff --git a/src/beats.js b/src/beats.js index 67bd691..b1c3b89 100644 --- a/src/beats.js +++ b/src/beats.js @@ -8,7 +8,7 @@ // input <-> gameplay: the imported bindings are only touched inside functions, never at load.) import { TUNE, UI, bossArt, COURT, HOME } from './config.js'; -import { G, P, ball, score, setupPoint, sendBoy } from './state.js'; +import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js'; @@ -227,7 +227,7 @@ export function startMatch() { G.boy = null; score.pts = [0, 0]; matchOpponent(); - ball.active = false; setupPoint(); + matchReady(); zombieDuck(true); voice('play_resume', 0.2); if (!G.seen.match) { @@ -238,6 +238,22 @@ export function startMatch() { return true; } +// Every point starts like a real one: the server behind his line (setupPoint's tennis branch) and +// THE RECEIVER ON ITS MARK — the zombie is dropped onto the receiver position the tennis branch +// just computed for O, instead of standing wherever the last rally abandoned it. Oliver: "it spawns +// at places where it is impossible to react to my serve. so i win like always." An opponent that +// cannot reach the serve is not weak, it is absent. +// ...and G.serveNum resets HERE, because the match bypasses tennis's pointTo (the only other place +// that resets it). Without this, the first fault of the match armed 'second serve' PERMANENTLY: +// every missed serve after it was a double fault, a point, and blood — the death loop Oliver hit. +function matchReady() { + ball.active = false; + G.serveNum = 1; + setupPoint(); + const rz = zombies.find(z => z.variant === 'r'); + if (rz) { rz.x = O.x; rz.z = 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; } +} + function matchOpponent() { spawnRacketZombie(); const zb = zombies.find(z => z.variant === 'r'); @@ -279,7 +295,7 @@ function matchPoint(side) { if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost(); voice(scoreKey(), 0.4); if (side === 'P') cheer(0.8, 0.3); - ball.active = false; setupPoint(); // next point, same opponent + matchReady(); // next point, same opponent, both on their marks } function matchGameWon(zb, banked = false) { @@ -294,9 +310,9 @@ function matchGameWon(zb, banked = false) { voice('game_player', 0.3); cheer(2.4, 0.6); G.card = { title: 'GAME.', sub1: 'NEXT OPPONENT', color: UI.pick, t: 2.2 }; d.opp++; - matchOpponent(); // fresh body, more hp, its own intro beat + matchOpponent(); // fresh body, its own intro beat d.ending = false; - ball.active = false; setupPoint(); + matchReady(); G.phase = 'over'; G.overT = 2.2; } @@ -304,7 +320,7 @@ function matchGameLost() { // Reachable only through deuce (four straight points is exactly your hp) — the body keeps its // wounds, the scoreboard banks their game, and the match simply plays on. voice('game_opponent', 0.3); aww(1); - ball.active = false; setupPoint(); + matchReady(); G.phase = 'over'; G.overT = 1.8; } diff --git a/src/config.js b/src/config.js index ba95234..2f41a56 100644 --- a/src/config.js +++ b/src/config.js @@ -386,8 +386,13 @@ export const TUNE = { matchPlayerHp: 8, // your pool for the MATCH — richer than the gore mode's 4, because a real game // has rallies you deserve to lose without the run ending ("the player is too // weak in this mode"). Their point still costs 1; matchHeal is how you last - matchSpeedPer: 0.12, // racketAI speed grows this much per opponent felled... - matchFlubKeep: 0.82, // ...and its whiff chance keeps only this much — the ladder now that hp is flat + matchSpeedBase: 1.4, // racketAI speed multiplier for opponent ONE — the duel's 2.8 m/s lumber was + // tuned for a single sudden-death point and read as "very weak and slow" + // across a whole game (~3.9 m/s now, rising per rung) + matchSpeedPer: 0.12, // ...growing this much per opponent felled + matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all + // returns, which across a game is an opponent that beats itself + matchFlubKeep: 0.82, // ...and it keeps only this much per rung — the ladder now that hp is flat matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate) // The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the // sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie: diff --git a/src/gameplay.js b/src/gameplay.js index 63c86fb..10ef51d 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -950,8 +950,10 @@ function racketAI(rz, dt) { const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); if (d > 0.15) { // THE MATCH LADDER ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard, - // so a harder opponent is a FASTER one that flubs less — the same knobs, scaled per body felled. - const spd = TUNE.racketSpeed * (G.match ? 1 + (G.match.opp - 1) * TUNE.matchSpeedPer : 1); + // so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's + // 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a + // whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase. + const spd = TUNE.racketSpeed * (G.match ? TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer : 1); const step = Math.min(spd * dt, d); rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; @@ -961,7 +963,9 @@ function racketAI(rz, dt) { if (rz.swingCool <= 0 && rz.swingT < 0 && Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach && ball.y < CONTACT.maxY && ball.y > CONTACT.minY) { - const flub = TUNE.racketFlub * (G.match ? Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) : 1); + // ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away, + // which across a game is an opponent that beats itself. + const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) : TUNE.racketFlub; if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; } } diff --git a/test.js b/test.js index 790ef21..c6ce2a5 100644 --- a/test.js +++ b/test.js @@ -81,7 +81,7 @@ globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- -const { G, P, score, ball, setupPoint } = await import('./src/state.js'); +const { G, P, O, score, ball, setupPoint } = await import('./src/state.js'); const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -557,11 +557,36 @@ 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 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 + // until dead. Oliver hit it: "when trying to serve after i got hurt i get hurt again and again + // until i die". + startMatch(); + G.serveNum = 2; // a fault has burned the first serve + resolveInterlude('O'); // the point is lost (double fault, passing shot, whatever) + assert.equal(G.serveNum, 1, 'the NEXT point starts on a first serve'); + resolveInterlude('P'); + assert.equal(G.serveNum, 1, '...whoever won it'); +}); + +test('every point starts with the receiver on its mark', () => { + startMatch(); + const rz = oppOf(); + rz.x = 6; rz.z = 12; // stranded mid-court by the last rally + resolveInterlude('P'); + assert.ok(Math.abs(rz.x) < 3 && rz.z <= 1.5, + 'reset to the receiver spot, not wherever the rally left it: x=' + rz.x.toFixed(1) + ' z=' + rz.z.toFixed(1)); + assert.equal(rz.x, O.x, 'on the exact mark the tennis branch computed for the receiver'); +}); + test('the ladder escalates in speed and reliability, not in hp', () => { // pure arithmetic on the knobs racketAI reads — pinned so a future tuning pass cannot silently // flatten the ladder again + assert.ok(TUNE.matchSpeedBase > 1, 'opponent one is already quicker than the duel lumber'); assert.ok(TUNE.matchSpeedPer > 0, 'each opponent is faster'); - assert.ok(TUNE.matchFlubKeep < 1, 'and whiffs less'); + assert.ok(TUNE.matchFlub < TUNE.racketFlub, 'the match whiffs less than the duel from the start'); + assert.ok(TUNE.matchFlubKeep < 1, 'and less again per rung'); }); test('a game won heals, so a clean player can go the distance', () => {