Sharpen league and Climb difficulty and give Gore a front door

This commit is contained in:
wuwi committed 2026-09-07 09:53:49 +02:00
1 parent e3faeb0a3a
commit ede1f48152
6 files changed
+151 -51

No files matched your search

+34
View File
@@ -4888,6 +4888,40 @@ per late opponent check quality ceilings and open-court frequency, with Club Pro
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.
## Session log: THE RETEST — sustained final-league returns, a brisker Climb, Gore at the front (2026-09-07, v350)
Oliver still defends league 5 reliably at level 26; the first 6–7 Climb opponents feel like filler.
The previous final-league pass raised quality CAPS but left the .45 floor intact. Raising pace
traits alone would do nothing here: their return-pace multiplier already hits its cap of 1.
- Brute / Director / Champion now return in quality bands .62–.76 / .68–.83 / .74–.90, and use
open-court aim on 50% / 70% / 85% of returns. `trySwing` reads the optional foe `qMin` just as
it already reads classic's floor. These final-league traits follow the body into cup, training
and Climb. Player level/peer gifts, opponent legs, wrong-footing, wounds and cup length stay put.
- **Climb-only opening**: `gameplay.matchStep` adds four progression steps at opponent one,
tapering linearly to zero at opponent eight. Movement (including intercept planning), return
pace, width and flub all read it. Actual opponent identity, territory pace and learning still
read the real rung. Cup onboarding and its training reps retain their old early curve.
Measured on actual update/return paths: the first Climb foe chases at 3.407 vs 2.520 m/s,
averages 18.639 vs 14.817 m/s return pace, reaches 2.364 vs 1.595 m return width, and returns
1152 vs 1073 of 1200 contact attempts. The existing auto-run reachability soak still passes.
- Oliver chose the **direct Gore door** over a mid-season teaser: SPLATTER GORE sits below the
primary league fixture and starts Gore in one tap with the selected court/kit. OTHER GAMES,
STATS and BOARD share one quiet navigation row (44px touch targets); their headline numbers
are removed from the splash. Court/kit setup remains accessible through OTHER GAMES.
- Badge glints peak at the START of their long CSS cycle; the first peak is now .39–.89 s after
creation instead of ~5.5–19 s. The three points stagger, repeat slowly and still respect reduced
motion. Existing art and typography are reused; no generated assets or dependencies.
Validation: 132 tests pass with the default seed plus 1, 7, 42, 99, 1234, 2026, 8888 and 20260907.
The return sampler measures mean quality .688 / .756 / .820 and open-corner shares 66.8% / 80.1% /
90.7% for the three final opponents. New Climb regression exercises real movement and returns,
including convergence at eight and unchanged cup/training behavior. Browser checked at 390×844
and 320×568; all splash buttons fit, Gore starts directly, no browser errors in that flow.
This is stronger measured opposition, not a measured human win rate: league 5 still needs
Oliver's level-26 phone verdict. Oliver requested merging to main and pushing for his iPhone
test; this release carries the cache-busting entry version v350.
# OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks.
+24 -17
View File
@@ -374,8 +374,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#cdBadges { display: flex; gap: 10px; align-items: flex-end; }
#cdBadges .badge { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
align-items: center; gap: 4px; }
/* ...and an earned shield SMOULDERS: two-pixel embers drifting off it, each on its own delay and
lane (main.embers). Pure CSS — the menu parks the game loop and this has no business waking it. */
/* ...and it CATCHES THE LIGHT: three points on the shield, dark for most of a long cycle and
flaring for a fraction of it (main.glints). Not a fire — a glint, the way metal behaves. */
#cdBadges .badge em { position: absolute; width: 4px; height: 4px; border-radius: 50%;
@@ -383,9 +381,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
opacity: 0; pointer-events: none; animation-name: glint;
animation-timing-function: ease-out; animation-iteration-count: infinite; }
@keyframes glint {
0%, 88%, 100% { opacity: 0; transform: scale(0.3); }
92% { opacity: 1; transform: scale(1); }
96% { opacity: 0.35; transform: scale(0.6); } }
0%, 12%, 100% { opacity: 0; transform: scale(0.3); }
4% { opacity: 1; transform: scale(1); }
8% { opacity: 0.35; transform: scale(0.6); } }
@media (prefers-reduced-motion: reduce) { #cdBadges .badge em { display: none; } }
#cdBadges img { height: 46px; image-rendering: pixelated; }
#cdBadges .badge u { font: 700 7px Silkscreen, monospace; color: var(--acid); letter-spacing: 0.5px;
@@ -659,8 +657,16 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
padding-bottom: max(10px, env(safe-area-inset-bottom));
border-top: 2px solid var(--line); background: var(--solid); }
.foot .btn { margin-bottom: 0; }
/* ...except the splash's own secondary door: OTHER GAMES sat flush on the STATS/RANKINGS row */
.foot.bare #mOther { margin-bottom: 12px; }
/* Gore gets a front door; the records become quiet navigation, with full touch targets. */
#mGore { display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 9px 12px; border-color: #804338; background: #281b19; color: #f1d6bf; }
#mGore b { font: 400 23px/1.1 Bangers, cursive; letter-spacing: 1px; }
#mGore i { margin: 0; color: #d5aa91; font: 400 9px/1.5 Silkscreen, monospace; letter-spacing: 0; }
.splashLinks { display: flex; gap: 4px; margin-top: 5px; }
.splashLinks .btn.sec { flex: 1; width: auto; min-height: 44px; padding: 7px 2px;
background: transparent; border-color: transparent; color: #ccc4b5;
font-size: 16px; letter-spacing: 0; }
.splashLinks #mOther { flex: 1.5; }
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */
.foot.bare { border: 0; background: none; }
.foot.bare .btn.big { margin-bottom: 10px; }
@@ -680,10 +686,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
animation: dealIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
#intro.deal #splash #plaque { animation-delay: 0.02s; }
#intro.deal #splash #mStart { animation-delay: 0.12s; }
#intro.deal #splash #mOther { animation-delay: 0.19s; }
/* the pair of secondary buttons is a CLASS, so it needs the same weight as the shorthand above
#intro.deal #splash #mGore { animation-delay: 0.19s; }
/* the navigation row is a CLASS, so it needs the same weight as the shorthand above
to override its delay — the id selectors around it get there on their own */
#intro.deal #splash .foot.bare > .two { animation-delay: 0.26s; }
#intro.deal #splash .foot.bare > .splashLinks { animation-delay: 0.26s; }
#intro.deal #splash #build { animation-delay: 0.33s; }
@media (prefers-reduced-motion: reduce) {
#intro.deal #splash #plaque, #intro.deal #splash .foot.bare > * { animation: none; } }
@@ -696,7 +702,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#boot u { display: block; margin-top: 7px; text-align: center; text-decoration: none;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
#splash.busy #boot { display: block; }
#splash.busy .btn, #splash.busy .two, #splash.busy #build { display: none; }
#splash.busy .btn, #splash.busy .splashLinks, #splash.busy #build { display: none; }
/* NOTHING DATA-DRIVEN DURING THE BOOT (2026-08-31, Oliver: "die oberfläche ist hier regelmäßig
in einem undefinierten zustand"). The plaque used to stand there as a skeleton, which is still
a plaque saying LEVEL 1 to a level-25 player for as long as the profile takes to land. The
@@ -847,12 +853,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<!-- THE FRONT DOOR IS THE FIXTURE (2026-08-31, THE SEASON). The primary button used to say
PLAY MATCH and open a wall of posters, which asked the player a question the game
already knows the answer to. It names the next cup fixture now and enters the draw;
everything else lives one tap down, behind OTHER GAMES. -->
Gore has its own secondary door; the other modes live behind OTHER GAMES. -->
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
<button class="btn sec" id="mOther">OTHER GAMES</button>
<div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0</i></button>
<button class="btn sec" id="mRanks">BOARD<i id="mBest">BEST 0</i></button>
<button class="btn sec" id="mGore"><b>SPLATTER GORE</b><i>NO CUP.<br>JUST CARNAGE.</i></button>
<div class="splashLinks">
<button class="btn sec" id="mOther">OTHER GAMES</button>
<button class="btn sec" id="mStats">STATS</button>
<button class="btn sec" id="mRanks">BOARD</button>
</div>
<p id="build"></p>
</div>
@@ -1082,6 +1089,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=349"></script>
<script type="module" src="src/main.js?v=350"></script>
</body>
</html>
+4 -4
View File
@@ -1250,19 +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
// Final-league returns hunt the open court (smart) and have individual quality bands. 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.12, pace: 1.34, wide: 1.24, flub: 0.66, smart: 0.20, qMax: 0.60 },
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.12, pace: 1.34, wide: 1.24, flub: 0.66, smart: 0.50, qMin: 0.62, qMax: 0.76 },
{ 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, smart: 0.40, qMax: 0.65 },
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60, smart: 0.70, qMin: 0.68, qMax: 0.83 },
// 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, smart: 0.60, qMax: 0.70 },
line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38, smart: 0.85, qMin: 0.74, qMax: 0.90 },
];
// Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists
+14 -8
View File
@@ -275,7 +275,7 @@ function doHit(ch, quality, aim) {
// rungs take it back.
// ...and the RUNG's own pace trait rides on top, inside the cap: full pace is still the ceiling,
// an opponent just gets there sooner or later than the flat curve would (see matchFoe in config).
s *= Math.min(1, (TUNE.matchOppPace + (G.match.opp - 1) * TUNE.matchOppPacePer) * (ch.foe?.pace || 1));
s *= Math.min(1, (TUNE.matchOppPace + matchStep() * TUNE.matchOppPacePer) * (ch.foe?.pace || 1));
}
if (G.match) {
// THE OPPONENT'S WIDTH IS THE THIRD DIAL. Its returns scatter off aimError x (1 - 0.55
@@ -283,7 +283,7 @@ function doHit(ch, quality, aim) {
// balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung
// earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one.
if (ch.side !== 'P') {
const wide = (TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer) * (ch.foe?.wide || 1);
const wide = (TUNE.matchWideBase + matchStep() * TUNE.matchWidePer) * (ch.foe?.wide || 1);
tx = Math.max(-wide, Math.min(wide, tx));
}
}
@@ -1172,8 +1172,15 @@ function matchFallWillPlay() {
// One definition, because the read boost, the wrong-foot slow and the surprise whiff must all
// answer "how smart is this one" identically or the twist pulls in three directions at once.
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
// The Climb is a repeatable arcade run: less nursery at the bottom. Four extra progression
// steps taper to zero at opponent eight; identities, territory tempo and cup onboarding stay put.
// Share this across movement, intercept planning and returns so all four dials agree.
const matchStep = () => {
const n = G.match.opp - 1;
return n + (!G.net && !G.match.cup && !G.match.train ? 4 * Math.max(0, 1 - n / 7) : 0);
};
const rzChaseSpeed = rz => TUNE.racketSpeed * leaguePace()
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1)
* (G.match ? (TUNE.matchSpeedBase + matchStep() * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1)
: 1 + TUNE.duelSpeedMax * duelSkill())
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
@@ -1456,10 +1463,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
: [ -1, 0, 1 ][Math.floor(Math.random() * 3)],
az: Math.random() < 0.25 ? -1 : 0 }
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
// G.diff (classic difficulty) moves the FLOOR too: a raised qMin is what makes HARD's every
// ball arrive with pace, where a cap alone just trims the best ones. Null outside classic, so
// the racket zombie and the gore opener keep the TUNE floor.
const qMin = G.diff ? G.diff.qMin : TUNE.aiQualityMin;
// A quality floor makes strong opponents sustain pressure, instead of only improving their
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
if (!isAI && G.mode === 'tennis') {
G.swingSnaps = null; G.rallyNear = null;
@@ -1540,7 +1546,7 @@ function racketAI(rz, dt) {
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
// which across a game is an opponent that beats itself.
const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, matchStep()) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub * (1 - TUNE.duelFlubCut * duelSkill()))
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
+10 -18
View File
@@ -600,13 +600,12 @@ const countUp = (write, target, dur = 700) => {
};
// A SHIELD CATCHES THE LIGHT (2026-08-31, Oliver: not burning — "manchmal random schimmern").
// The first cut was a chimney of embers, which read as a fire on a trophy. This is the opposite:
// three points ON the shield, each dark for most of a long cycle and flaring for a fraction of it,
// on delays and durations that share no common multiple — so a glint lands somewhere on the row
// every couple of seconds and never twice in the same rhythm. CSS keyframes, no game loop.
const glints = won => !won ? '' : Array.from({ length: 3 }, () =>
// Three points ON the shield: the first catches the light within a second of opening the card,
// then the long, varied cycles keep it occasional. CSS keyframes, no game loop.
const glints = won => !won ? '' : Array.from({ length: 3 }, (_, i) =>
`<em style="left:${(18 + Math.random() * 60).toFixed(0)}%;` +
`top:${(12 + Math.random() * 52).toFixed(0)}%;` +
`animation-delay:${(Math.random() * 9).toFixed(2)}s;` +
`animation-delay:${(0.15 + i * 0.6 + Math.random() * 0.3).toFixed(2)}s;` +
`animation-duration:${(6 + Math.random() * 5).toFixed(2)}s"></em>`).join('');
function buildCard() {
@@ -737,21 +736,11 @@ document.getElementById('prGo').addEventListener('click', () => {
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
// has just ended and the count has moved.
// The splash keeps career progress on the plaque; detailed records live behind STATS and BOARD.
// Re-read on each arrival because the preceding run may have moved the level or league.
function refreshSplash() {
// THE TEXT REDUCTION (2026-08-31): the two secondary doors carry a NUMBER each instead of a
// description — what you have done, and your own best on the board they open. The board figure
// is read locally on purpose: the splash does not fetch the world board.
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB');
document.getElementById('mBest').textContent =
'BEST ' + stats.bestScore(boardFor(pickedMode)).toLocaleString('en-GB');
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
// moved it (same rule as the kill count above). One row now: crest, name and level, and the bar
// moved it. One row now: crest, name and level, and the bar
// under them wearing the level it fills toward. The goal LINE is gone — the fixture button below
// says what to do, and it said it twice.
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
@@ -819,6 +808,9 @@ document.getElementById('mStart').addEventListener('click', () => {
pickMode('cup'); start('cup');
});
document.getElementById('mOther').addEventListener('click', goMode);
document.getElementById('mGore').addEventListener('click', () => {
pickMode('gore'); start('gore');
});
document.getElementById('mStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
// whose names you came to look at
+65 -4
View File
@@ -929,16 +929,17 @@ test('league 5 has no speed dip below league 4, including serve and wrong-footed
endMatch();
});
test('league 5 returns hunt the open court and use their own quality caps; league 4 stays unchanged', () => {
test('league 5 sustains strong returns and hunts the open court; 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');
assert.equal(cfg.matchFoe(n).qMin, undefined, 'no new quality floor 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]]) {
for (const [n, smart, floor, cap] of [[15, 0, 0.45, 0.55], [16, 0.5, 0.62, 0.76], [17, 0.7, 0.68, 0.83], [18, 0.85, 0.74, 0.90]]) {
G.match.opp = n; zb.foe = cfg.matchFoe(n);
let hits = 0, open = 0, maxQ = 0;
let hits = 0, open = 0, maxQ = 0, sumQ = 0;
for (let i = 0; i < 1200; i++) {
G.phase = 'rally'; G.wasServe = false;
G.match.surprised = G.match.expected = false;
@@ -949,7 +950,8 @@ test('league 5 returns hunt the open court and use their own quality caps; leagu
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`);
assert.ok(ball.pow >= floor && ball.pow <= cap, `${n}: quality ${ball.pow} within its band`);
sumQ += ball.pow;
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;
@@ -959,6 +961,7 @@ test('league 5 returns hunt the open court and use their own quality caps; leagu
}
assert.ok(hits > 1100, `${n}: enough actual returns (${hits})`);
assert.ok(maxQ > cap - 0.002, `${n}: the live return reaches its cap (${maxQ})`);
assert.ok(Math.abs(sumQ / hits - (floor + cap) / 2) < 0.01, `${n}: normal returns sustain the stronger quality`);
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}`);
@@ -966,6 +969,64 @@ test('league 5 returns hunt the open court and use their own quality caps; leagu
endMatch();
});
test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => {
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
const measure = (n, mode) => {
G.match.opp = n; G.match.train = mode === 'train';
G.match.cup = mode === 'cup' ? { round: 0, rounds: [{ need: 1 }] } : null;
G.match.surprised = G.match.expected = false;
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
const chase = Math.hypot(zb.vx, zb.vz);
let hits = 0, pace = 0, width = 0;
const seed = _seed;
_seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls
for (let i = 0; i < 1200; i++) {
G.phase = 'rally'; G.wasServe = false;
P.x = 0; 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;
hits++; pace += Math.hypot(ball.vx, ball.vz);
const t = (ball.vy + Math.sqrt(ball.vy ** 2 + 2 * ball.g * ball.y)) / ball.g;
width = Math.max(width, Math.abs(ball.x + ball.vx * t));
ball.active = false;
}
_seed = seed;
return { chase, hits, pace: pace / hits, width };
};
let previous = 0;
for (let n = 1; n <= 8; n++) {
const cup = measure(n, 'cup'), train = measure(n, 'train'), climb = measure(n, 'climb');
const same = (a, b, why) => {
assert.equal(a.chase, b.chase, why + ': chase');
assert.ok(Math.abs(a.pace / b.pace - 1) < 0.005, why + ': return pace');
assert.ok(Math.abs(a.width - b.width) < 0.12, why + ': width ' + a.width + ' / ' + b.width);
assert.ok(Math.abs(a.hits - b.hits) < 40, why + ': whiff rate');
};
same(train, cup, `${n}: the training rep matches its cup opponent`);
assert.ok(climb.chase > previous, `${n}: the arcade's early chase curve keeps rising`);
previous = climb.chase;
if (n < 8) assert.ok(climb.chase > cup.chase, `${n}: less opening assistance in arcade`);
else same(climb, cup, 'opponent eight rejoins the original curve');
if (n === 1) {
assert.ok(climb.chase / cup.chase > 1.3, 'the opening opponent covers materially more ground');
assert.ok(climb.pace / cup.pace > 1.2, 'its actual returns arrive faster');
assert.ok(climb.width / cup.width > 1.4, 'its actual returns use more of the court');
assert.ok(climb.hits > cup.hits + 40, 'it gives away fewer returns');
assert.ok(climb.width < 2.5, 'opening returns still leave a reachable margin');
}
}
G.match.cup = null; G.match.train = false;
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.