From e3faeb0a3affa7b64be74a91251db9dfbfda34a2 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 4 Sep 2026 09:11:12 +0200 Subject: [PATCH] Strengthen final-league placement and returns --- PROJECT.md | 23 ++++++++++++++++++ index.html | 2 +- src/config.js | 8 ++++--- src/gameplay.js | 14 +++++++---- test.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 6a9c01e..91936eb 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -4865,6 +4865,29 @@ MATCHED` / `YOU HAVE THE EDGE`) beneath the equal-size level pair. Three dials l match tuning (`peerGap`, `peerRun`, `peerWindow`); no profile state was added. Suite 129 pins the zero/parity bounds, material level-25-to-parity gain, cup/training scope, and arcade/netplay exits. +## Session log: THE FINAL LEAGUE FIGHTS BACK (2026-09-04) + +After the peer-gift retest Oliver cleared the remaining cup with only medium struggle. Approved: +keep all player-level/peer bonuses, strengthen league 5's placement AND return quality now, and +allow no final-league opponent to run slower than anyone in league 4. Do not lengthen the cup. + +The three final opponents reuse classic tennis's existing open-court aim: Brute 20%, Director 40%, +Champion 60% deliberately target the corner opposite the player; the remaining returns still roll +a random lane. Their return quality ceilings rise from the shared .55 to .60 / .65 / .70, retaining +the .45 floor. These are opponent traits, so the same bodies behave consistently in cup, training, +and arcade. League 1–4, classic, netplay, gore, and every player/peer bonus are untouched. + +Brute's speed trait rises from 1.02 to 1.12; Director and Champion already clear the league-4 peak. +The first candidate, 1.10, cleared the neutral chase but narrowly failed the equally wrong-footed +comparison in the live movement test. 1.12 clears both. The speed invariant compares like-for-like +conditions (normal/serve, neutral/surprised); wrong-footing itself remains a deliberate opening. + +Two regression tests exercise actual update()/racketAI returns and movement, not just table values: +all league-5 bodies clear the fastest league-4 chase in all four conditions; 1,200 return attempts +per late opponent check quality ceilings and open-court frequency, with Club Pro as the unchanged +control. All 131 tests pass with the default seed and eight additional seeds. Build v349 targets +staging only. Combined difficulty still needs Oliver's playtest; shot statistics are not win rates. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 3c07486..444f392 100644 --- a/index.html +++ b/index.html @@ -1082,6 +1082,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 630033e..bae63d8 100644 --- a/src/config.js +++ b/src/config.js @@ -1250,17 +1250,19 @@ const OPPONENTS = [ line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 }, // THE BRUTE, not "THE BULK": the gore mode's stats screen has called this body THE BRUTE since it // was the pole-dragger (ENEMIES.g below), and one character may not carry two names. + // Final-league returns hunt the open court (smart) and have individual quality caps. Earlier + // opponents keep random lanes and racketQMax. Brute's legs must clear even league 4's fastest. { short: 'BRUTE', name: 'THE BRUTE', fam: 'br', h: 2.0, - line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.02, pace: 1.34, wide: 1.24, flub: 0.66 }, + line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.12, pace: 1.34, wide: 1.24, flub: 0.66, smart: 0.20, qMax: 0.60 }, { short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr', - line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60 }, + line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60, smart: 0.40, qMax: 0.65 }, // 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the // serve aims at the body actually drawn): four family rolls could not make the art itself bulky // against the pose sheet — see _famroll.py's pair-reference note — so his size is stature. // Six new rungs stand between him and the start now, so he took the step up Oliver asked for: // he reads the court faster, hits harder, paints wider, and misses half as often as he did. { short: 'CHAMPION', name: 'THE CHAMPION', fam: 'ch', h: 2.1, - line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38 }, + line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38, smart: 0.60, qMax: 0.70 }, ]; // Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists diff --git a/src/gameplay.js b/src/gameplay.js index bf171b4..87ec5b3 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1444,11 +1444,13 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // finger is usually still down at contact, and the vector under it NOW is the aim the player // means — the released flickAim only carries it when the ball arrives after the finger lifted. const flick = isAI ? null : (G.swipe || takeFlickAim()); - // SMART AIM (G.diff.smart, classic difficulty): with this probability the CPU plays the OPEN + // SMART AIM (classic difficulty or the solo match foe): with this probability the CPU plays the OPEN // COURT — the corner away from where you stand — instead of rolling a random lane. Random aim // sends a third of everything straight back to your racket; hunting the open corner is what // makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit). - const smart = isAI && G.diff && Math.random() < (G.diff.smart || 0); + const smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0; + // Keep the old random sequence for classic and for foes without smart aim. + const smart = isAI && (G.diff || smartChance) && Math.random() < smartChance; const aim = isAI ? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1)) : [ -1, 0, 1 ][Math.floor(Math.random() * 3)], @@ -1545,8 +1547,12 @@ function racketAI(rz, dt) { // body had actually learned. The champion's near-zero flub is exactly what this opens. + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); 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 + TUNE.duelQBonus * duelSkill()); rz.swingT = -1; rz.swingCool = 0.3; } + else { + const qCap = G.match && !G.net ? (rz.foe?.qMax ?? TUNE.racketQMax) + : TUNE.racketQMax + TUNE.duelQBonus * duelSkill(); + rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; + trySwing(rz, true, qCap); rz.swingT = -1; rz.swingCool = 0.3; + } // the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet if (G.match) G.match.surprised = G.match.expected = false; } diff --git a/test.js b/test.js index 6692e3b..c03aba1 100644 --- a/test.js +++ b/test.js @@ -904,6 +904,68 @@ test('the opponent card is a RAMP: it starts easy and only gets harder', () => { assert.equal(expectLo, LADDER + 1, 'the five bands cover the ladder exactly'); }); +test('league 5 has no speed dip below league 4, including serve and wrong-footed chases', () => { + startMatch(); G.interlude.introT = 0; + const zb = oppOf(); + const measure = (n, serve, surprised) => { + G.match.opp = n; G.match.surprised = surprised; G.match.expected = false; + zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999; + G.phase = 'rally'; G.wasServe = serve; + Object.assign(ball, { active: true, lastHit: 'P', bounces: 0, + x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 }); + update(DT); + return Math.hypot(zb.vx, zb.vz); + }; + for (const serve of [false, true]) for (const surprised of [false, true]) { + const fourth = cfg.leagueOf(4).band, fifth = cfg.leagueOf(5).band; + let floor = 0; + for (let n = fourth[0]; n <= fourth[1]; n++) floor = Math.max(floor, measure(n, serve, surprised)); + assert.ok(floor > 7, 'the rig measures a real full-speed chase'); + for (let n = fifth[0]; n <= fifth[1]; n++) { + const speed = measure(n, serve, surprised); + assert.ok(speed >= floor, `${cfg.matchFoe(n).short}: ${speed} must clear ${floor} (serve ${serve}, surprised ${surprised})`); + } + } + endMatch(); +}); + +test('league 5 returns hunt the open court and use their own quality caps; league 4 stays unchanged', () => { + startMatch(); G.interlude.introT = 0; + const zb = oppOf(); + for (let n = 1; n <= cfg.leagueOf(4).band[1]; n++) { + assert.equal(cfg.matchFoe(n).smart, undefined, 'no new aim bonus in earlier leagues'); + assert.equal(cfg.matchFoe(n).qMax, undefined, 'no new quality bonus in earlier leagues'); + } + for (const [n, smart, cap] of [[15, 0, 0.55], [16, 0.2, 0.60], [17, 0.4, 0.65], [18, 0.6, 0.70]]) { + G.match.opp = n; zb.foe = cfg.matchFoe(n); + let hits = 0, open = 0, maxQ = 0; + for (let i = 0; i < 1200; i++) { + G.phase = 'rally'; G.wasServe = false; + G.match.surprised = G.match.expected = false; + P.x = i % 2 ? 3 : -3; P.z = 23; P.swingT = -1; + zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; + Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, + x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); + update(DT); + if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent + hits++; + assert.ok(ball.pow >= TUNE.aiQualityMin && ball.pow <= cap, `${n}: quality ${ball.pow} within its band`); + maxQ = Math.max(maxQ, ball.pow); + const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g; + const landingX = ball.x + ball.vx * t; + // At q >= .45 the corner's inward scatter stays beyond 1.35; centre scatter stays inside. + if (landingX * Math.sign(P.x) < -1.35) open++; + ball.active = false; + } + assert.ok(hits > 1100, `${n}: enough actual returns (${hits})`); + assert.ok(maxQ > cap - 0.002, `${n}: the live return reaches its cap (${maxQ})`); + const expected = 1 / 3 + 2 / 3 * smart; + assert.ok(Math.abs(open / hits - expected) < 0.05, + `${n}: open-court share ${open / hits} should be near ${expected}`); + } + endMatch(); +}); + test('the first opponent cannot paint lines: its returns are width-clamped', () => { // aimError x (1 - 0.55 quality) scattered returns to +-3.5 m, which an auto-run player cannot // cover — "still a LOT of unreachable balls". Width is the ladder's third dial now.