From 7e2649c8881d62e0e9e302cdc6c8e73311a2d077 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 30 Aug 2026 13:28:24 +0200 Subject: [PATCH] The level is you, in the league: quicker legs, a wider window, and a curve that pays on the way up Co-Authored-By: Claude Fable 5 --- PROJECT.md | 21 +++++++++++++++++++++ index.html | 2 +- src/gameplay.js | 18 ++++++++++++++---- src/profile.js | 22 +++++++++++++++------- test.js | 35 +++++++++++++++++++++++++++++++---- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 7694b03..dddaf49 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,27 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: THE LEVEL IS YOU, IN THE LEAGUE — legs and window (2026-08-29, after the merge) + +Oliver's deep-test question ("wie wirkt sich mein level aus? werde ich schneller, genauer?") +exposed two honest gaps: the level's buffs were defensive-only (the plan's serve/accuracy dials +never landed) and linearly spread over 49 levels — imperceptible where he actually plays +(level 20, league 4: "autorun schlicht zu langsam, das timing wird zu tight"). His directive: +gore aside, the league must reward xp SUBTLY but really; level 1 stays the untouchable baseline. + +- **profile.bonus() is FRONT-LOADED now**: sqrt of the level fraction (the xp curve's own + fast-early logic). Level 20 carries ~0.62 of each cap value; linear gave 0.39. +- **THE LEAGUE PAIR**, league modes only (`G.match && !G.net` — classic keeps its picker, + netplay stays a fair human match, gore untouched): `run` (cap +15%) multiplies INSIDE + `assistK()`, so the mover's legs and intercept's reachability can never disagree (the + rzChaseSpeed rule, player-side); `window` (cap +20%) widens trySwing's player contact window + AND the update() swing-clock expiry that would otherwise close over it — and since quality is + centre-normalised, the same millisecond error also scores HIGHER: quicker to the ball, cleaner + when there. At level 20 that is ~+9% legs, ~+12% window. +- Suite 125: level-1 identity, cap bounds, the front-load ratio, the league/netplay/classic + gating — and freshSplatter zeroes xp now, because the level feeds real dials and accumulated + test awards would silently buff every later test. + ## THE LEAGUES LAND ON MAIN (2026-08-29, end of the build day) Oliver's phone verdict after the full staging day: "super — sehr gute session. bitte merge nach diff --git a/index.html b/index.html index 54a1faa..4a198bb 100644 --- a/index.html +++ b/index.html @@ -864,6 +864,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/gameplay.js b/src/gameplay.js index 8cb5c47..540e2bb 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1315,7 +1315,16 @@ function onBounce() { // them, so the same swing whiffed centimetres short there. Gore's opener keeps the old feel: its // tennis is a prologue, not the mode. export const rallyFeel = () => !!(G.match || G.classic); -export const assistK = () => rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist; +// THE LEVEL IS YOU, in the league (2026-08-29): the player's legs and contact window grow subtly +// with the level — LEAGUE modes only (the climb, the cup, the training court). Classic keeps its +// own difficulty picker, netplay is a fair human match, gore is untouched (its trio lives in +// profile.bonus too, read elsewhere). Level 1 is factor 1: the shipped baseline is the floor. +export const legLevel = () => G.match && !G.net ? bonus().run : 1; +export const winLevel = () => G.match && !G.net ? bonus().window : 1; +// legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never +// disagree about what those legs can get to (the rzChaseSpeed rule, player-side). +export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist) + * legLevel(); function intercept(side) { const ch = side === 'O' ? O : P; @@ -1409,7 +1418,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // dies on contact feeling fair, and the gore modes are untouched. const reach = isAI ? (G.diff ? G.diff.reach : TUNE.aiReach) : TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); - const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1); + const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel()); const t = ch.swingT; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; if (!ball.active || G.phase !== 'rally') return; @@ -1986,12 +1995,13 @@ export function update(dt) { const volley = ball.bounces === 0; (G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y, bz: ball.z, bounces: ball.bounces, - r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) }); + r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() }); } trySwing(P, false); // a contact resets swingT to -1 inside trySwing // volley-length window while a hostile shot is in the air too, or the swing clock expires at // 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance - const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1); + const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1) + * winLevel(); // the clock must outlive the widened window or the level's gift is dead time if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing } expirePose(P, dt); diff --git a/src/profile.js b/src/profile.js index 73ab675..bf511b8 100644 --- a/src/profile.js +++ b/src/profile.js @@ -89,14 +89,22 @@ export function award(kind, n = 1) { } // ---------------------------------------------------------------- the level is you ---- -// Phase 4's defensive buffs, through existing dials only: level 1 is the shipped game (factor 1) -// and the cap stays honest — a maxed profile survives volleys better, it does not stop dying. -// Read at the dial sites (meleeCooldown's floor, the parry's reach/arm, hurtPlayer's grace). +// The level's buffs, through existing dials only: level 1 is the shipped game (factor 1, the +// untouchable baseline) and the caps stay honest. FRONT-LOADED (sqrt of the level fraction, +// 2026-08-29): Oliver plays at level ~20 and the buffs must be felt on the way up, not only at +// the cap — the xp curve itself is fast-early for the same reason. At level 20 the fraction is +// ~0.62 of the cap value; linear gave 0.39. +// Read at the dial sites: meleeCooldown's floor, the parry's reach/arm, hurtPlayer's grace (the +// gore trio), and — THE LEAGUE PAIR (his league-4 report: "autorun schlicht zu langsam, timing +// zu tight") — gameplay.assistK's legs and trySwing's contact window, league modes only. export function bonus() { - const f = (levelAt(prof.xp) - 1) / (LEVEL_CAP - 1); - return { melee: 1 - 0.25 * f, // the swish floor drops to 75% — panic scales with mastery - parry: 1 + 0.25 * f, // the parry reaches a quarter further - grace: 1 + 0.35 * f }; // a volley needs a third longer to land twice + const f = Math.sqrt((levelAt(prof.xp) - 1) / (LEVEL_CAP - 1)); + return { melee: 1 - 0.25 * f, // the swish floor drops to 75% — panic scales with mastery + parry: 1 + 0.25 * f, // the parry reaches a quarter further + grace: 1 + 0.35 * f, // a volley needs a third longer to land twice + run: 1 + 0.15 * f, // the league autorun's legs, up to +15% + window: 1 + 0.20 * f }; // the league contact window, up to +20% — cleaner timing too: + // quality is centre-normalised, so the same ms error scores higher } // ---------------------------------------------------------------- the promotion ---- diff --git a/test.js b/test.js index 5e7efa7..188c8c1 100644 --- a/test.js +++ b/test.js @@ -89,7 +89,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, leaguePace, duelSkill, tinLadder, deliverTin, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); +const { update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = 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 @@ -111,8 +111,10 @@ const DT = 1 / 60; function freshSplatter() { restartRun(); // THE LEAGUES: a won cup PROMOTES the profile (by design), which would leak league 2 into every - // test after the tournament walkthrough — each test opens in league 1 with no ceremony pending - prof.prof.lg = 1; prof.prof.ch = 0; prof.takePromo(); + // test after the tournament walkthrough — each test opens in league 1 with no ceremony pending. + // XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from + // earlier tests would silently buff every later one. + prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo(); G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); @@ -2325,12 +2327,18 @@ test('THE LEVEL IS YOU: the defensive buffs scale with level and level one is th const xp0 = prof.prof.xp; prof.prof.xp = 0; const b1 = prof.bonus(); - assert.deepEqual(b1, { melee: 1, parry: 1, grace: 1 }, 'level one changes nothing'); + assert.deepEqual(b1, { melee: 1, parry: 1, grace: 1, run: 1, window: 1 }, 'level one changes nothing'); prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); const bMax = prof.bonus(); assert.ok(bMax.melee < 1 && bMax.melee >= 0.7, 'the swish floor drops, boundedly'); assert.ok(bMax.parry > 1 && bMax.parry <= 1.3, 'the parry reaches further, boundedly'); assert.ok(bMax.grace > 1 && bMax.grace <= 1.4, 'the grace stretches, boundedly'); + assert.ok(bMax.run > 1 && bMax.run <= 1.15, 'the legs quicken, boundedly'); + assert.ok(bMax.window > 1 && bMax.window <= 1.2, 'the window widens, boundedly'); + // FRONT-LOADED: level 20 must already carry more than half the cap's gift (Oliver plays there) + prof.prof.xp = prof.xpFor(20); + assert.ok((prof.bonus().run - 1) / (bMax.run - 1) > 0.5, 'the curve pays on the way up'); + prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); // back to the cap for the grace-landing check below // ...and the grace actually lands on the player: hurtPlayer writes the scaled window G.mode = 'splatter'; G.hurtT = 0; G.dodgeI = 0; G.hp = 4; hurtPlayer(1); @@ -2338,5 +2346,24 @@ test('THE LEVEL IS YOU: the defensive buffs scale with level and level one is th prof.prof.xp = xp0; }); +test('THE LEVEL IS YOU, in the league: legs and window grow with xp — baseline, classic and netplay untouched', () => { + const legLevel = _legLevel, winLevel = _winLevel, assistK = _assistK; + prof.prof.xp = 0; + G.match = { opp: 5 }; + const legs0 = assistK(); + assert.equal(legLevel(), 1, 'level one IS the shipped game'); + assert.equal(winLevel(), 1, '...window too'); + prof.prof.xp = prof.xpFor(20); + assert.ok(legLevel() > 1.05 && assistK() > legs0, 'level 20 legs are visibly quicker in the league'); + assert.ok(winLevel() > 1.05, '...and the contact window visibly wider'); + G.net = { on: true }; + assert.equal(legLevel(), 1, 'netplay is a fair human match — no level legs'); + assert.equal(winLevel(), 1, '...and no level window'); + G.net = null; G.match = null; G.classic = true; + assert.equal(legLevel(), 1, 'classic keeps its own difficulty picker'); + G.classic = false; + prof.prof.xp = 0; +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report