diff --git a/PROJECT.md b/PROJECT.md index 9ff5e61..ea14d50 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -636,6 +636,16 @@ Each phase ships to staging and gets a phone verdict, and tests ride along with 5. **LATER.** The metal bed (when Oliver has a track: start at the transformation, gore only) and Members' Night (option C). +## Session log: no story boss crosses the net (2026-10-15, v385, STAGING ONLY) +Oliver: *"strawberry seller as first endboss is way too hard — or it should not cross the net"*. The +seller is the arcade's chapter-9 design. His move is `charge`, the one style deliberately exempt from +the z band: it rushes to a strike line in front of you, and contact is death. He stays act I's +mid-boss. Every story boss now carries `holdNet` (`beats.startStoryBoss`): a rush stops +`TUNE.bossNetStop` (1 m) short of the net and then withdraws as before. The threat is kept, the reach +is not. That covers the Physio in act IV too. Only THE CHAMPION as act V's boss keeps his MATCH POINT +rush, because it is the finale. Test: the seller rushes at least twice in 40 s and never passes +netZ - 1; the champion is not held. + ## Session log: THE STORY, Oliver's first test (2026-10-15, v384, STAGING ONLY) Oliver: *"after my first test it all made a good impression"*, plus five asks. - **The metal track** ("Dystopian Survival", 3:36). `assets/audio/metal-loop.mp3` is 0.5-214 s with the diff --git a/index.html b/index.html index 25016c0..1910dd1 100644 --- a/index.html +++ b/index.html @@ -1201,6 +1201,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index cc96ca4..e135a5b 100644 --- a/src/beats.js +++ b/src/beats.js @@ -1268,6 +1268,9 @@ function startStoryBoss(kind) { const act = G.story.act; G.story.fight = kind; startBossFight(storyBossChapter(act, kind), storyBossArt(act, kind)); + // no story boss crosses the net — except THE CHAMPION, whose MATCH POINT rush IS the finale + const b = zombies.find(z => z.variant === 'B'); + if (b) b.holdNet = !(act === 5 && kind === 'end'); } // THE MID-BOSS IS DOWN: the checkpoint. The rack is topped back up to the act's stock (never down), diff --git a/src/config.js b/src/config.js index de3b6dd..19b2e92 100644 --- a/src/config.js +++ b/src/config.js @@ -318,6 +318,7 @@ export const TUNE = { bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp // above just made that MORE likely, not less. Only 'charge' breaks the band. bossChargeV: 4.2, // m/s the physio rushes at once it commits + bossNetStop: 1.0, // m short of the net a STORY boss's rush stops (holdNet): the threat without the reach bossChargeT: 2.2, // s ONE RUSH takes, start to arrival — the charge speed is derived from it and // from how far away it is (bossAI), so the run-up reads the same every time and // is long enough to see coming. Was 1.5 s at a flat 4.2 m/s, which only reached diff --git a/src/zombies.js b/src/zombies.js index c5df419..566bbf4 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -202,6 +202,10 @@ function bossAI(zb, dt) { // turns and withdraws the instant it arrives. A rush that misses is over. zb.x += Math.sign(zb.lane - zb.x) * Math.min(Math.abs(zb.lane - zb.x), TUNE.bossStrafe * 2 * dt); zb.z += zb.chargeV * dt; + // THE STORY's bosses keep their own half (Oliver, 2026-10-15: the strawberry seller as act I's first + // boss was "way too hard — or it should not cross the net"): the rush runs at you and stops short + // of the net, a threat you read and punish rather than an instant death. (beats.startStoryBoss) + if (zb.holdNet) zb.z = Math.min(zb.z, COURT.netZ - TUNE.bossNetStop); if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // spent: it turns and walks back } else { // AND IT FALLS BACK. A rush that ended where it landed left the boss parked on top of you for diff --git a/test.js b/test.js index 71f74dd..ff8112f 100644 --- a/test.js +++ b/test.js @@ -3812,6 +3812,32 @@ test('THE STORY: the help offer — fresher legs and a longer swing in the cup, G.horde = null; }); +test('THE STORY: a boss never crosses the net — the strawberry seller rushes and stops short (the champion still comes)', () => { + intoStory(1, 'chap'); + for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } + const b = zombies.find(z => z.variant === 'B'); + assert.equal(b.art.name, 'THE STRAWBERRY SELLER'); + assert.ok(b.holdNet, 'held to its half'); + let maxZ = -99, rushes = 0, was = false; + for (let i = 0; i < 40 * 60 && zombies.includes(b); i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.phase = 'pretoss'; G.hurtT = 1; + update(DT); + maxZ = Math.max(maxZ, b.z); + if (b.charging && !was) rushes++; + was = !!b.charging; + } + assert.ok(rushes >= 2, 'it still rushes: ' + rushes); + assert.ok(maxZ <= COURT.netZ - TUNE.bossNetStop + 1e-9, `it stops short of the net: max z ${maxZ.toFixed(2)}`); + assert.equal(G.mode, 'splatter', 'and it never reached you'); + zombies.length = 0; endBossFight(); + // the champion's finale keeps its rush + restartRun(); + intoStory(5, 'mid'); + for (let g = cfg.STORY.midAt; g < cfg.STORY.length; g++) { storyGame(); G.modal = null; } + const c = zombies.find(z => z.variant === 'B'); + assert.equal(c.art.name, 'THE CHAMPION'); assert.ok(!c.holdNet, 'the champion still comes for you'); +}); + test('THE STORY: a barrel holds what the act has earned', () => { intoStory(1, 'chap'); const seen = new Set();