Strengthen final-league placement and returns
This commit is contained in:
1 parent
bfc70d7388
commit
e3faeb0a3a
5 files changed
+101
-8
No files matched your search
+23
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user