Strengthen final-league placement and returns

This commit is contained in:
wuwi committed 2026-09-04 09:11:12 +02:00
1 parent bfc70d7388
commit e3faeb0a3a
5 files changed
+101 -8

No files matched your search

+23
View File
@@ -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.
+1 -1
View File
@@ -1082,6 +1082,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=348"></script>
<script type="module" src="src/main.js?v=349"></script>
</body>
</html>
+5 -3
View File
@@ -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
+10 -4
View File
@@ -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;
}
+62
View File
@@ -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.