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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-30 13:28:24 +02:00
1 parent 7dc72009eb
commit 7e2649c888
5 files changed
+82 -16

No files matched your search

+21
View File
@@ -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
+1 -1
View File
@@ -864,6 +864,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=332"></script>
<script type="module" src="src/main.js?v=333"></script>
</body>
</html>
+14 -4
View File
@@ -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);
+15 -7
View File
@@ -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 ----
+31 -4
View File
@@ -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