Difficulty moves the dials a player can feel: reaction, reach, floor, serve

"using the difficulty in classic tennis I don't feel any difference" — and he
was right, verified live: G.diff arrives intact, the dials were just the
wrong ones. flub/cap/speed barely register: the quality cap trims a
distribution whose 0.45 floor never moved (EASY and HARD averaged 0.59 vs
0.70), and ±12% on aiSpeed never binds because the CPU pre-plans its
intercept. What a player reads as SKILL is how fast the CPU reacts to the
shot (aiReaction — the config's own comment says corners are winners because
of it), how long its racket is (aiReach), the FLOOR of its shot quality (a
raised qMin means every ball comes back with pace), and the serve — none of
which the first cut touched, and the serve was hardcoded 0.6-0.95 besides.

DIFFS carries seven dials now with real spread: EASY whiffs one ball in
three, reads late, swings short and serves soft; HARD reads in 0.28s, swings
1.75m, floors quality at 0.62 with a cap of 1.0, and serves 10% hotter.
MEDIUM is pinned dial-for-dial to the shipped TUNE values by test, so the
middle chip remains exactly yesterday's classic. All G.diff-guarded, so the
racket zombie and gore's opener keep their own numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 11:50:44 +02:00
1 parent 6b795f77cc
commit 77bc68043d
4 files changed
+33 -14

No files matched your search

+1 -1
View File
@@ -471,6 +471,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=235"></script>
<script type="module" src="src/main.js?v=236"></script>
</body>
</html>
+10 -7
View File
@@ -650,14 +650,17 @@ export const MENU = [
{ mode: 'gore' },
];
// CLASSIC's difficulty, the one thing the modes screen adds to gameplay: a scaling on the three
// dials the classic CPU already reads (flub = error rate, quality = shot cap, speed = aiSpeed
// multiplier). MEDIUM is the game exactly as it shipped — classicFlub/classicQualityMax repeated
// here so the middle chip can never drift from what "no difficulty" used to play like.
// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING —
// the quality distributions overlapped (0.45 floor either way), ±12% speed never binds because the
// CPU pre-plans its intercept, and the dials that actually read as skill were untouched. What a
// player FEELS is: how fast the CPU reads a shot (reaction — "well-aimed corners are winners"),
// how long its racket is (reach), the FLOOR of its shot quality (qMin — a raised floor means every
// ball comes back with pace), how often it just misses (flub), and its serve. MEDIUM repeats the
// shipped TUNE values dial for dial (pinned by test), so the middle chip IS the old game.
export const DIFFS = [
{ id: 'easy', name: 'EASY', flub: 0.22, quality: 0.72, speed: 0.88 },
{ id: 'medium', name: 'MEDIUM', flub: 0.10, quality: 0.86, speed: 1.0 },
{ id: 'hard', name: 'HARD', flub: 0.04, quality: 0.95, speed: 1.12 },
{ id: 'easy', name: 'EASY', flub: 0.30, quality: 0.62, qMin: 0.45, speed: 0.80, reaction: 0.62, reach: 1.45, serve: 0.75 },
{ id: 'medium', name: 'MEDIUM', flub: 0.10, quality: 0.86, qMin: 0.45, speed: 1.0, reaction: 0.45, reach: 1.6, serve: 1.0 },
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.62, speed: 1.18, reaction: 0.28, reach: 1.75, serve: 1.1 },
];
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
+10 -4
View File
@@ -1154,7 +1154,8 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// sampled 1.3-1.6 m — centimetres outside the 1.3 m post-bounce reach, point after point. The
// assist can deliver you NEAR the ball but not INSIDE a phone-tuned reach; a rally mode lives or
// dies on contact feeling fair, and the gore modes are untouched.
const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const reach = isAI ? (G.diff ? G.diff.reach : TUNE.aiReach)
: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1);
const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
@@ -1176,7 +1177,11 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const aim = isAI
? { ax: [ -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) };
if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (qCap - TUNE.aiQualityMin));
// 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;
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;
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
@@ -1711,14 +1716,15 @@ export function update(dt) {
if (G.aiServeT <= 0) tossBall(O);
} else if (oServes && G.phase === 'toss') {
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) {
G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35);
G.wasServe = true; hitServe(O, Math.min(1, (0.6 + Math.random() * 0.35) * (G.diff ? G.diff.serve : 1)));
// ...and the body that just served holds the strike, so the swing reads as its own (beats)
if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; }
}
}
if (G.mode === 'tennis' && G.phase === 'rally') {
G.aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction;
const incoming = ball.active && ball.lastHit === 'P'
&& G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction);
const tgt = incoming ? intercept('O') : HOME.O;
const spd = TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points
const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz);
+12 -2
View File
@@ -1114,15 +1114,25 @@ test('classic wears the match feel, and the difficulty table is a ladder', () =>
assert.ok(rallyFeel(), 'classic is a rally mode');
assert.ok(assistK() >= TUNE.matchAssist, 'classic gets the match assist floor');
G.classic = false;
// ...and DIFFS climbs monotonically, with MEDIUM pinned to the shipped classic dials so the
// middle chip can never drift from what "no difficulty" used to play like.
// ...and DIFFS climbs monotonically on every dial, with MEDIUM pinned dial-for-dial to the
// shipped TUNE values so the middle chip can never drift from what "no difficulty" played like.
// (The first cut scaled only flub/cap/speed and Oliver felt no difference — reaction, reach,
// qMin and serve are the dials a player actually reads as skill.)
const [e, m, h] = cfg.DIFFS;
assert.ok(e.flub > m.flub && m.flub > h.flub, 'easier flubs more');
assert.ok(e.quality < m.quality && m.quality < h.quality, 'harder hits better');
assert.ok(e.speed < m.speed && m.speed < h.speed, 'harder runs faster');
assert.ok(e.reaction > m.reaction && m.reaction > h.reaction, 'harder reads the shot sooner');
assert.ok(e.reach < m.reach && m.reach < h.reach, 'harder swings a longer racket');
assert.ok(e.qMin <= m.qMin && m.qMin < h.qMin, 'harder raises the quality floor');
assert.ok(e.serve < m.serve && m.serve < h.serve, 'harder serves better');
assert.equal(m.flub, TUNE.classicFlub, 'MEDIUM flub IS classicFlub');
assert.equal(m.quality, TUNE.classicQualityMax, 'MEDIUM quality IS classicQualityMax');
assert.equal(m.speed, 1.0, 'MEDIUM leaves aiSpeed alone');
assert.equal(m.qMin, TUNE.aiQualityMin, 'MEDIUM floor IS aiQualityMin');
assert.equal(m.reaction, TUNE.aiReaction, 'MEDIUM reaction IS aiReaction');
assert.equal(m.reach, TUNE.aiReach, 'MEDIUM reach IS aiReach');
assert.equal(m.serve, 1.0, 'MEDIUM leaves the serve alone');
});
// stats.js ships its own fixture-based check — the period filter, the mode split and the name