Sharpen league and Climb difficulty and give Gore a front door
This commit is contained in:
1 parent
e3faeb0a3a
commit
ede1f48152
6 files changed
+151
-51
No files matched your search
+34
@@ -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
|
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.
|
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
|
# OPEN — leftovers, in rough priority order
|
||||||
|
|
||||||
Nothing here is broken; these are known gaps and unverified risks.
|
Nothing here is broken; these are known gaps and unverified risks.
|
||||||
|
|||||||
+24
-17
@@ -374,8 +374,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
#cdBadges { display: flex; gap: 10px; align-items: flex-end; }
|
#cdBadges { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
#cdBadges .badge { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
|
#cdBadges .badge { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||||
align-items: center; gap: 4px; }
|
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
|
/* ...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. */
|
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%;
|
#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;
|
opacity: 0; pointer-events: none; animation-name: glint;
|
||||||
animation-timing-function: ease-out; animation-iteration-count: infinite; }
|
animation-timing-function: ease-out; animation-iteration-count: infinite; }
|
||||||
@keyframes glint {
|
@keyframes glint {
|
||||||
0%, 88%, 100% { opacity: 0; transform: scale(0.3); }
|
0%, 12%, 100% { opacity: 0; transform: scale(0.3); }
|
||||||
92% { opacity: 1; transform: scale(1); }
|
4% { opacity: 1; transform: scale(1); }
|
||||||
96% { opacity: 0.35; transform: scale(0.6); } }
|
8% { opacity: 0.35; transform: scale(0.6); } }
|
||||||
@media (prefers-reduced-motion: reduce) { #cdBadges .badge em { display: none; } }
|
@media (prefers-reduced-motion: reduce) { #cdBadges .badge em { display: none; } }
|
||||||
#cdBadges img { height: 46px; image-rendering: pixelated; }
|
#cdBadges img { height: 46px; image-rendering: pixelated; }
|
||||||
#cdBadges .badge u { font: 700 7px Silkscreen, monospace; color: var(--acid); letter-spacing: 0.5px;
|
#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));
|
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||||
border-top: 2px solid var(--line); background: var(--solid); }
|
border-top: 2px solid var(--line); background: var(--solid); }
|
||||||
.foot .btn { margin-bottom: 0; }
|
.foot .btn { margin-bottom: 0; }
|
||||||
/* ...except the splash's own secondary door: OTHER GAMES sat flush on the STATS/RANKINGS row */
|
/* Gore gets a front door; the records become quiet navigation, with full touch targets. */
|
||||||
.foot.bare #mOther { margin-bottom: 12px; }
|
#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 */
|
/* 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 { border: 0; background: none; }
|
||||||
.foot.bare .btn.big { margin-bottom: 10px; }
|
.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; }
|
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 #plaque { animation-delay: 0.02s; }
|
||||||
#intro.deal #splash #mStart { animation-delay: 0.12s; }
|
#intro.deal #splash #mStart { animation-delay: 0.12s; }
|
||||||
#intro.deal #splash #mOther { animation-delay: 0.19s; }
|
#intro.deal #splash #mGore { animation-delay: 0.19s; }
|
||||||
/* the pair of secondary buttons is a CLASS, so it needs the same weight as the shorthand above
|
/* 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 */
|
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; }
|
#intro.deal #splash #build { animation-delay: 0.33s; }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
#intro.deal #splash #plaque, #intro.deal #splash .foot.bare > * { animation: none; } }
|
#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;
|
#boot u { display: block; margin-top: 7px; text-align: center; text-decoration: none;
|
||||||
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
|
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
|
||||||
#splash.busy #boot { display: block; }
|
#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
|
/* 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
|
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
|
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
|
<!-- 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
|
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;
|
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 big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
|
||||||
<button class="btn sec" id="mOther">OTHER GAMES</button>
|
<button class="btn sec" id="mGore"><b>SPLATTER GORE</b><i>NO CUP.<br>JUST CARNAGE.</i></button>
|
||||||
<div class="two">
|
<div class="splashLinks">
|
||||||
<button class="btn sec" id="mStats">STATS<i id="mKills">0</i></button>
|
<button class="btn sec" id="mOther">OTHER GAMES</button>
|
||||||
<button class="btn sec" id="mRanks">BOARD<i id="mBest">BEST 0</i></button>
|
<button class="btn sec" id="mStats">STATS</button>
|
||||||
|
<button class="btn sec" id="mRanks">BOARD</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="build"></p>
|
<p id="build"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1082,6 +1089,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=349"></script>
|
<script type="module" src="src/main.js?v=350"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+4
-4
@@ -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 },
|
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
|
// 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.
|
// 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.
|
// 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,
|
{ 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',
|
{ 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
|
// 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
|
// 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.
|
// 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:
|
// 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.
|
// 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,
|
{ 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
|
// Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists
|
||||||
|
|||||||
+14
-8
@@ -275,7 +275,7 @@ function doHit(ch, quality, aim) {
|
|||||||
// rungs take it back.
|
// rungs take it back.
|
||||||
// ...and the RUNG's own pace trait rides on top, inside the cap: full pace is still the ceiling,
|
// ...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).
|
// 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) {
|
if (G.match) {
|
||||||
// THE OPPONENT'S WIDTH IS THE THIRD DIAL. Its returns scatter off aimError x (1 - 0.55
|
// 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
|
// 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.
|
// earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one.
|
||||||
if (ch.side !== 'P') {
|
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));
|
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
|
// 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.
|
// 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;
|
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()
|
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())
|
: 1 + TUNE.duelSpeedMax * duelSkill())
|
||||||
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
|
* (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
|
// 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)],
|
: [ -1, 0, 1 ][Math.floor(Math.random() * 3)],
|
||||||
az: Math.random() < 0.25 ? -1 : 0 }
|
az: Math.random() < 0.25 ? -1 : 0 }
|
||||||
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
|
: 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
|
// A quality floor makes strong opponents sustain pressure, instead of only improving their
|
||||||
// ball arrive with pace, where a cap alone just trims the best ones. Null outside classic, so
|
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
|
||||||
// the racket zombie and the gore opener keep the TUNE floor.
|
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
|
||||||
const qMin = G.diff ? G.diff.qMin : TUNE.aiQualityMin;
|
|
||||||
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
|
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
|
||||||
if (!isAI && G.mode === 'tennis') {
|
if (!isAI && G.mode === 'tennis') {
|
||||||
G.swingSnaps = null; G.rallyNear = null;
|
G.swingSnaps = null; G.rallyNear = null;
|
||||||
@@ -1540,7 +1546,7 @@ function racketAI(rz, dt) {
|
|||||||
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
|
&& 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,
|
// ...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.
|
// 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()))
|
: TUNE.racketFlub * (1 - TUNE.duelFlubCut * duelSkill()))
|
||||||
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
|
// 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
|
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
|
||||||
|
|||||||
+10
-18
@@ -600,13 +600,12 @@ const countUp = (write, target, dur = 700) => {
|
|||||||
};
|
};
|
||||||
// A SHIELD CATCHES THE LIGHT (2026-08-31, Oliver: not burning — "manchmal random schimmern").
|
// 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:
|
// 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,
|
// Three points ON the shield: the first catches the light within a second of opening the card,
|
||||||
// on delays and durations that share no common multiple — so a glint lands somewhere on the row
|
// then the long, varied cycles keep it occasional. CSS keyframes, no game loop.
|
||||||
// every couple of seconds and never twice in the same rhythm. CSS keyframes, no game loop.
|
const glints = won => !won ? '' : Array.from({ length: 3 }, (_, i) =>
|
||||||
const glints = won => !won ? '' : Array.from({ length: 3 }, () =>
|
|
||||||
`<em style="left:${(18 + Math.random() * 60).toFixed(0)}%;` +
|
`<em style="left:${(18 + Math.random() * 60).toFixed(0)}%;` +
|
||||||
`top:${(12 + Math.random() * 52).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('');
|
`animation-duration:${(6 + Math.random() * 5).toFixed(2)}s"></em>`).join('');
|
||||||
|
|
||||||
function buildCard() {
|
function buildCard() {
|
||||||
@@ -737,21 +736,11 @@ document.getElementById('prGo').addEventListener('click', () => {
|
|||||||
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
|
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
|
||||||
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
: 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 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.
|
||||||
// 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.
|
|
||||||
function refreshSplash() {
|
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
|
// 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
|
// 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.
|
// says what to do, and it said it twice.
|
||||||
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
|
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
|
||||||
@@ -819,6 +808,9 @@ document.getElementById('mStart').addEventListener('click', () => {
|
|||||||
pickMode('cup'); start('cup');
|
pickMode('cup'); start('cup');
|
||||||
});
|
});
|
||||||
document.getElementById('mOther').addEventListener('click', goMode);
|
document.getElementById('mOther').addEventListener('click', goMode);
|
||||||
|
document.getElementById('mGore').addEventListener('click', () => {
|
||||||
|
pickMode('gore'); start('gore');
|
||||||
|
});
|
||||||
document.getElementById('mStats').addEventListener('click', openStats);
|
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
|
// 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
|
// whose names you came to look at
|
||||||
|
|||||||
@@ -929,16 +929,17 @@ test('league 5 has no speed dip below league 4, including serve and wrong-footed
|
|||||||
endMatch();
|
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;
|
startMatch(); G.interlude.introT = 0;
|
||||||
const zb = oppOf();
|
const zb = oppOf();
|
||||||
for (let n = 1; n <= cfg.leagueOf(4).band[1]; n++) {
|
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).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).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);
|
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++) {
|
for (let i = 0; i < 1200; i++) {
|
||||||
G.phase = 'rally'; G.wasServe = false;
|
G.phase = 'rally'; G.wasServe = false;
|
||||||
G.match.surprised = G.match.expected = 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);
|
update(DT);
|
||||||
if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent
|
if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent
|
||||||
hits++;
|
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);
|
maxQ = Math.max(maxQ, ball.pow);
|
||||||
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
|
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
|
||||||
const landingX = ball.x + ball.vx * t;
|
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(hits > 1100, `${n}: enough actual returns (${hits})`);
|
||||||
assert.ok(maxQ > cap - 0.002, `${n}: the live return reaches its cap (${maxQ})`);
|
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;
|
const expected = 1 / 3 + 2 / 3 * smart;
|
||||||
assert.ok(Math.abs(open / hits - expected) < 0.05,
|
assert.ok(Math.abs(open / hits - expected) < 0.05,
|
||||||
`${n}: open-court share ${open / hits} should be near ${expected}`);
|
`${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();
|
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', () => {
|
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
|
// 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.
|
// cover — "still a LOT of unreachable balls". Width is the ladder's third dial now.
|
||||||
|
|||||||
Reference in new issue
Block a user