Let an early tap be a weak ball, and let the CPU put a sitter away

Step 4 (b), Oliver's pick. v372 measured why the ladder could not climb:
nearly every lost point was the player's own early whiff, the CPU barely
missed, and the auto-run reached everything, so no opponent dial decided
a point.

- The follow-through: a groundstroke swing meets a bounced ball up to
  60 ms after its window at q 0.05. The early whiff edge goes from ~70 ms
  back to ~130 ms, where the volley window had it before step 1.
- A sitter (a player ball under q 0.5) is put away with a chance that is
  the ladder (0.2 at the bottom of the Climb, 0.85 at the Champion, by
  difficulty in classic): clean, at the line, bounded by the rung's width.
- The player is on his heels for 0.5 s after a put-away of his own sitter.
  Without it the auto-run covered the whole court and no put-away won.
- Fixed a step-2 regression this surfaced: the drop's dig-out cap fired on
  every slice drive, so every low contact against the slicer was a sitter.

Measured at level 26: the Climb now slopes from 95% of points at the
Shambler to 28-39% at the Champion. The net rushers are still easier than
their neighbours. The log names put-aways. Three new tests; v373.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 18:25:49 +02:00
1 parent 5dd5d71c33
commit 4406aeafdb
8 files changed
+231 -31

No files matched your search

+46 -3
View File
@@ -5585,6 +5585,48 @@ sein, dass durch die geänderte Mechanik alles etwas schwerer geworden ist"*.
- (c) **Widen the early whiff edge only** (a smaller tail), keeping the error race: undoes most of the
"harder" without changing the model.
### Step 4 (b), Oliver's pick: the follow-through, and a CPU that can WIN a point (2026-10-14, v373)
Oliver: *"b"* (options above). Two halves, each useless alone (both were measured alone above):
- **THE FOLLOW-THROUGH** (`TUNE.swingTail` 0.06 s, rally modes, player, bounced balls only). A swing
meets a ball arriving up to 60 ms after its window closed at q 0.05. The early whiff edge goes from ~70
ms back to ~130 ms (where the volley window had it before step 1); past it, it is still a whiff. The
log says `q=0.05 · early 115ms`. Volleys keep their window: a tail on balls in the air raised the
back-court volley share.
- **THE SITTER IS PUNISHED** (`gameplay.punishes`): a player ball under `punishBelow` (0.5) is answered
with a put-away at a chance from `punishMin` 0.2 to `punishMax` 0.85 along the Climb's own progress
(`matchStep`, so its brisk bottom counts), classic by `DIFFS[].punish` (0.2/0.5/0.8). A put-away is
struck at `punishQ` 0.95, skips the style's soft shots, aims at `punishX` 3.5 m (the line; the rung's
width dial still bounds it, so the bottom of the ladder punishes without painting lines), and only
counts if no cap took it down (a low volley stays a block). The log says `THEM PUT-AWAY of your weak ball`.
- **...AND HE IS ON HIS HEELS** (`ball.heels`, `punishRead` 0.5 s): the auto-run stands for that long on a
put-away of his own sitter. This is the half that makes it work. The auto-run started on the strike's
frame with a perfect read, ~7 m/s plus a 1.75 m reach, and covered the whole court: a q 0.95 put-away
to the open court won 0 of 21, at the line 0-2 of 20. Read 0.25 s: ~5%; 0.4: ~20%; 0.55: ~70%.
**Feel risk:** he visibly stands still for half a second. If that reads as a bug on the phone, the
alternatives are a slowed (not frozen) run, or a shorter read with the put-away hit harder.
- **A step-2 regression it surfaced:** the drop's dig-out cap read `ball.spin < 1`, and the slicer hits
EVERY rally ball at spin 0.8, so every low contact against him (both sides) came back at `lowVolleyQ`.
Now `ball.soft && spin < 1`. THE SELLER went from 35% to 47-54% and normal rally lengths.
- **Measured** (Climb, level 26, 8 seeds × 500 s, point win %):
| rung | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| σ40 | 95 | 88 | 88 | 87 | 75 | 58 | 52 | 45 | 54 | 48 | 47 | 77 | 42 | 46 | 64 | 40 | 65 | 39 |
| σ60 | 95 | 88 | 83 | 78 | 62 | 55 | 53 | 43 | 47 | 46 | 36 | 61 | 30 | 43 | 57 | 28 | 55 | 28 |
The ladder slopes now, down to the Champion at 28-39%. Classic: easy ~80, medium ~57, hard ~30 (σ60, level 1).
- **Still open:**
- (1) **The net rushers** (Umpire, Club Pro, Director) are 15-25 points easier than their neighbours.
They win only ~35-55% of their net points, because the bot ALWAYS passes or lobs a net man, which is
their tag's answer played perfectly. The phone decides whether a human does that; if so, the dial is
their split-step read (`netReact`) or their put-away from the net.
- (2) The bottom three rungs are ~90%. That is easy for level 26, but the Climb is a marathon.
- Tests (167, nine seeds): the follow-through meets an ~80 ms early tap (and the old edge did not); the
put-away share climbs the ladder, goes for the line, never answers a good ball; a low slice drive is not
a dug-out drop. The baseline-shape test excludes the answer to a put-away (a man on his heels takes it in
the air legitimately), and synthetic returns in the suite now carry `pow: 1` (a stale `pow` from the
previous test read as a sitter).
# OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks.
@@ -5592,9 +5634,10 @@ Nothing here is broken; these are known gaps and unverified risks.
0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converged on serve, both at the net,
reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the
opponent styles (v364) followed; the swing-log fixes ran to v371. **Step 4 (v372) is BLOCKED on a
design decision**: the ladder cannot climb while points are decided by the player's whiffs. See "Step
4, measured first" above for options (a)/(b)/(c). Fallback tag `rally-baseline-v360`.
opponent styles (v364) followed; the swing-log fixes ran to v371. Step 4: v372 measured why the ladder
could not climb (points decided by the player's whiffs); Oliver picked (b), shipped v373 (the follow-
through, put-aways, on his heels). Awaiting the phone verdict on v373, especially the half-second
flat-footed read and the net rushers. See "Step 4 (b)" above. Fallback tag `rally-baseline-v360`.
1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080
device px across). Oliver has not reported stutter, but he also has not played a late horde since.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=372"></script>
<script type="module" src="src/main.js?v=373"></script>
</body>
</html>
+12 -3
View File
@@ -153,6 +153,15 @@ export const TUNE = {
// screen: a short swipe is a safe half-court ball, and only a committed
// one goes hunting the line, which is what makes going OUT a chosen risk.
perfectMs: 45, // |offset from window center| for PERFECT
swingTail: 0.06, // s: rally modes, a groundstroke swing still meets a ball arriving this long after
// its window closes — the follow-through, q 0.05 (a weak short ball, not a whiff).
// 0 = the old edge: a tap ~70 ms early whiffed (step 4, see gameplay.trySwing)
punishBelow: 0.5, // a player ball under this q is a SITTER the CPU may put away (gameplay.punishes)
punishMin: 0.2, // ...the chance it does at the bottom of the ladder
punishMax: 0.85, // ...and at the CHAMPION (classic: DIFFS[].punish)
punishQ: 0.95, // the put-away's quality, played to the open court...
punishX: 3.5, // ...at this x, inside the singles line (4.1), bounded by the rung's width dial
punishRead: 0.5, // s the player's auto-run stands flat-footed on a put-away of his own sitter
qSeamless: 1, // rally modes: past perfectMs, q falls from 1 to the window edge with no step.
// 0 = the old curve, 1 - off/half, which dropped a groundstroke 1 ms past
// PERFECT straight to q 0.43 (step 4, 2026-10-14 — see gameplay.trySwing)
@@ -919,9 +928,9 @@ export const MENU = [{ mode: 'zombie' }, { mode: 'train' }, { mode: 'classic' }]
export const DIFFS = [
// EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD
// taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 },
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 },
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 },
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48, punish: 0.2 },
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66, punish: 0.5 },
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80, punish: 0.8 },
];
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
+43 -9
View File
@@ -268,6 +268,9 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
}
// a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it,
// so the bottom of the ladder cannot paint lines even when it punishes)
if (aim && aim.kill) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
@@ -384,6 +387,7 @@ function doHit(ch, quality, aim) {
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
ball.from = Math.abs(ch.z - COURT.netZ);
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
if (ch.side !== 'P' && swingLog()) {
@@ -393,7 +397,7 @@ function doHit(ch, quality, aim) {
const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ;
return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`;
})();
const kind = soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
const kind = aim && aim.kill ? 'PUT-AWAY of your weak ball' : soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
: soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball';
logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}`
+ (ball.approach === ch.side ? ' · coming in' : ''), px: r2(P.x), pz: r2(P.z) }); // where HE stood when it left
@@ -613,7 +617,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.spin = 1; ball.soft = false;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.spin = 1; ball.soft = false;
// a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot
ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ);
ball.pow = quality;
@@ -1650,6 +1654,25 @@ function logWhiff() {
...(P.tgt ? { runTo: r2(P.tgt.x) + ',' + r2(P.tgt.z) } : {}) }); // where the auto-run was taking him
}
// THE FOLLOW-THROUGH (step 4 (b), 2026-10-14). A tap a little EARLY on a baseline ball starts the swing
// while the ball is still in the air; the swing waits for the bounce (letBounce), and by then the
// groundstroke window has run out — a clean whiff at ~70 ms early, while a tap 120 ms LATE still met
// the ball. Before step 1 the same ball was volleyed in a 1.8x window, so it never came up. In the
// rally modes the swing now carries on for swingTail after its window and meets a ball arriving there
// at q 0.05: a weak short ball the opponent has to punish. Whether he does is what the ladder is.
const swingTail = isAI => !isAI && rallyFeel() ? TUNE.swingTail : 0;
// ...and the other half, without which the follow-through only made rallies endless: the CPU had NO
// way to win a point. It returned a floater exactly like a drive, its whiff rate is near zero past
// mid-ladder, and every point it took was a whiff of the player's — which no opponent dial touches, so
// the ladder could not climb. A sitter (the player's ball under punishBelow) is now PUT AWAY with a
// chance that IS the ladder: punishMin at the shambler to punishMax at the champion, classic by
// difficulty. Rally modes, solo only.
function punishes(ch) {
if (!rallyFeel() || G.net || ch.side === 'P' || (ball.pow ?? 1) >= TUNE.punishBelow) return false;
// the Climb's own progress (matchStep: its brisker bottom included), so a level-26 run is not filler
const k = G.diff ? (G.diff.punish ?? 0) : G.match ? TUNE.punishMin + (TUNE.punishMax - TUNE.punishMin) * Math.min(1, matchStep() / (LADDER - 1)) : 0;
return Math.random() < k;
}
function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// Contact happens if the ball is inside reach while the swing window is open.
// Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue.
@@ -1663,7 +1686,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel());
const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win + (volley ? 0 : swingTail(isAI))) return; // the tail meets a BOUNCED ball
if (!ball.active || G.phase !== 'rally') return;
// splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you —
// volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.)
@@ -1681,7 +1704,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// bounce frame (his v367 log, 8.98 s).
if (letBounce()) {
const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg;
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() - 0.03) return;
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() + swingTail(isAI) - 0.03) return;
}
// quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2;
@@ -1697,6 +1720,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
if (rallyFeel() && !isAI && TUNE.qSeamless && half > p)
q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p));
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
if (t > TUNE.swingWindup + win) q = 0.05; // the follow-through: met, barely
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
// the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the
// finger is usually still down at contact, and the vector under it NOW is the aim the player
@@ -1708,7 +1732,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit).
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;
// THE SITTER IS PUNISHED: rolled first and only on a sitter, so every other ball keeps its old sequence
const kill = isAI && punishes(ch);
const smart = kill || 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)],
@@ -1717,7 +1743,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop
// when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally
// ball it always hit, carrying its spin. One roll, so the shares are exactly the table's.
const st = isAI ? styleOf(ch) : null;
const st = isAI && !kill ? styleOf(ch) : null;
if (st) {
const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random();
const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5;
@@ -1730,15 +1756,19 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// 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 (kill) q = Math.max(q, TUNE.punishQ);
const q0 = q; // (the log names whichever cap below took it down)
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
// on its first frame in reach is often low, and capping those made every rally ball a sitter.
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
// ...and SOFT backspin balls only (step 4): step 2's slicer hits every rally ball at spin 0.8, so this
// capped every low contact against him, his own included — invisible until a sitter got punished.
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
// That is the lob's real reward — rarely the winner itself, nearly always the point.
if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ);
if (kill && q >= TUNE.punishQ) aim.kill = true; // a put-away only if no cap took it down (a low volley is a block)
if (!isAI && swingLog()) {
G.swingSnaps = null; G.rallyNear = null;
const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
@@ -2271,7 +2301,11 @@ export function update(dt) {
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
// ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn)
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
let rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
// ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run
// started on the strike's frame with a perfect read and covered the whole court — no put-away ever
// won a point, and the CPU still had no way to take one
if (chasing && ball.heels > 0) { ball.heels -= dt; rate = 0; }
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
// for a reason:
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
@@ -2368,7 +2402,7 @@ export function update(dt) {
// 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance
const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1)
* winLevel(); // the clock must outlive the widened window or the level's gift is dead time
if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
if (P.swingT > TUNE.swingWindup + Math.max(win, TUNE.swingWindow * winLevel() + swingTail(false)) + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
}
expirePose(P, dt);
+1 -1
View File
@@ -75,7 +75,7 @@ export function netSendShot() {
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
export function netApplyShot(m) {
const s = mirror(m);
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.approach = null;
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null;
ball.x = s.x; ball.y = m.y; ball.z = s.z;
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
ball.g = m.g; ball.pow = m.pow; ball.spin = m.spin || 1; // the soft ball's backspin: both phones bounce it alike
+2 -2
View File
@@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, rx: null, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false,
active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false,
// NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the
// striker's phone that is mid-roll, a whole court away from the call, and it read as the game
// loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring
@@ -206,7 +206,7 @@ export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.approach = null;
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; ball.approach = null;
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
// (the MATCH falls through to the tennis branch below:
+113 -11
View File
@@ -911,7 +911,7 @@ test('league 5 has no speed dip below league 4, including serve and wrong-footed
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,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
return Math.hypot(zb.vx, zb.vz);
@@ -946,7 +946,7 @@ test('league 5 sustains strong returns and hunts the open court; league 4 stays
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,
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, // a good ball: no put-away
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
@@ -1012,7 +1012,7 @@ test('match low contact: scoop rising balls, reject falling balls and contact ac
const rz = oppOf();
Object.assign(rz, { x: 0, z: Math.min(z, COURT.netZ - 1.2), swingT: -1, swingCool: 0 });
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1,
x: 0, y, z, vx: 0, vy, vz: -1, g: TUNE.gravity });
update(DT);
assert.equal(ball.lastHit === 'O', returns, `contact at z=${z}, y=${y}, vy=${vy}`);
@@ -1076,7 +1076,7 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
oppOf().foe = cfg.matchFoe(11); // the BODY plays the style, not the rung number
for (let i = 0; i < 2.2 * 60; i++) update(DT);
const hits = [];
let prev = ball.lastHit;
let prev = ball.lastHit, putAway = false;
for (let i = 0; i < 120 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 11; // keep playing: the shape is the subject
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
@@ -1085,11 +1085,14 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d < 2.9 && d > 2.2) keyPressed('Space');
}
const b0 = ball.bounces;
const b0 = ball.bounces, kill = putAway;
update(DT);
if (ball.active && ball.lastHit !== prev && !G.wasServe) {
const ch = ball.lastHit === 'P' ? P : oppOf();
if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT });
// the answer to a PUT-AWAY (step 4) is a defence, not the rally's shape: a man caught on his heels
// takes it where he can, in the air included
if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT, kill });
putAway = ball.heels > 0;
}
prev = ball.active ? ball.lastHit : null;
}
@@ -1098,7 +1101,7 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
// the autopilot mistimes into short balls and a body is MEANT to come in on those, so the share of
// net contacts follows its timing; the back-court volley share below is the guard against the old T
assert.ok(back.length / hits.length >= 0.5, `most of the rally is played from the back: ${back.length}/${hits.length}`);
const volleys = back.filter(h => h.volley).length;
const volleys = back.filter(h => h.volley && !h.kill).length;
assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`);
const gaps = [];
for (let k = 1; k < hits.length; k++) if (hits[k].back && hits[k - 1].back && hits[k].t - hits[k - 1].t < 3)
@@ -1143,6 +1146,105 @@ test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobo
} finally { TUNE.matchFlub = flub; }
});
test('THE FOLLOW-THROUGH: a tap ~80 ms early on a baseline ball is met weakly, not whiffed', () => {
// Step 4 (b), 2026-10-14. An early tap starts the swing while the ball is in the air, the swing waits
// for the bounce, and the groundstroke window had run out by then: a whiff at ~70 ms early, while a
// tap 120 ms LATE still met it. swingTail lets the swing meet it at q 0.05 — a sitter, not a miss.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
const run = () => {
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
for (let i = 0; i < 2.2 * 60; i++) update(DT);
let armed = false, incoming = 0, met = 0;
for (let i = 0; i < 90 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) {
const c = ringCue();
if (c && c.eta - c.sweet <= 5 / 60 + 1e-6 && c.eta - c.sweet > 4 / 60) { armed = true; incoming++; keyPressed('Space'); }
}
const before = ball.lastHit;
update(DT);
if (armed && before === 'O' && ball.lastHit === 'P') { met++; armed = false; }
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
}
endMatch();
return { incoming, met };
};
const tail = TUNE.swingTail;
try {
const on = run();
assert.ok(on.incoming >= 20, 'a real sample: ' + on.incoming);
assert.ok(on.met / on.incoming >= 0.8, `an ~80 ms early tap is met: ${on.met}/${on.incoming}`);
TUNE.swingTail = 0;
const off = run();
assert.ok(off.met / off.incoming < 0.6, `...where the old edge whiffed it: ${off.met}/${off.incoming}`);
} finally { TUNE.autoAssist = assist; TUNE.swingTail = tail; }
});
test('THE SITTER IS PUNISHED: the put-away chance climbs the ladder, goes for the line, and finds him on his heels', () => {
// Step 4 (b): before it, the CPU had no way to win a point — a q 0.05 floater came back like a drive
// and every point it took was the player's own whiff, which no opponent dial touches.
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
const share = (n, pow) => {
G.match.opp = n; zb.foe = cfg.matchFoe(n);
let hits = 0, kills = 0, wide = 0, heels = 0;
for (let i = 0; i < 1200; i++) {
G.phase = 'rally'; G.wasServe = false;
G.match.surprised = G.match.expected = 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, pow, rx: null, spin: 1, soft: false,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
hits++;
if (ball.heels > 0) {
kills++; heels++;
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
if (Math.abs(ball.x + ball.vx * t) > 2.9) wide++;
assert.ok(ball.pow >= TUNE.punishQ, 'a put-away is struck clean');
}
ball.active = false;
}
return { hits, kills: kills / hits, wide: kills ? wide / kills : 0 };
};
const champ = share(cfg.LADDER, 0.1), first = share(1, 0.1), good = share(cfg.LADDER, 0.9);
assert.ok(Math.abs(champ.kills - TUNE.punishMax) < 0.06, `the champion puts a sitter away ${champ.kills.toFixed(2)}`);
assert.ok(first.kills < champ.kills - 0.3 && first.kills > 0.1, `the first rung does it rarely ${first.kills.toFixed(2)}`);
assert.equal(good.kills, 0, 'a good ball is never put away');
assert.ok(champ.wide > 0.9, `the champion's put-away goes for the line ${champ.wide.toFixed(2)}`);
endMatch();
});
test('a low SLICE drive is not a dug-out drop: the scoop cap is for soft backspin balls only', () => {
// Step 2's slicer hits every rally ball at spin 0.8, and the drop's dig-out cap read `spin < 1`, so every
// low contact against him came back at lowVolleyQ — found when step 4 started punishing sitters.
startMatch(); G.interlude.introT = 0;
const zb = oppOf(); G.match.opp = 6; zb.foe = cfg.matchFoe(6);
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
try {
for (const soft of [false, true]) {
let n = 0, capped = 0;
for (let i = 0; i < 300; i++) {
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = 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, pow: 1, rx: null, spin: 0.8, soft,
x: 0, z: 2.4, y: 0.3, vx: 0, vy: 0.5, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
n++; if (ball.pow <= TUNE.lowVolleyQ + 1e-9) capped++;
ball.active = false;
}
assert.ok(n > 200, 'returns: ' + n);
if (soft) assert.equal(capped, n, 'a dug-out drop is still a weak reply');
else assert.equal(capped, 0, 'a low slice drive is an ordinary ball');
}
} finally { TUNE.matchFlub = flub; endMatch(); }
});
test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no cliff past perfectMs)', () => {
// Step 4's measurement (2026-10-14): q was 1 inside perfectMs and 1 - off/half outside it, so a
// groundstroke (half-window ~80 ms) fell from 1 to 0.43 a millisecond past PERFECT, and a tap 30 ms
@@ -1302,7 +1404,7 @@ function styleReturns(rung, pz, aiZ = 2) {
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = pz; P.swingT = -1;
zb.x = 0; zb.z = aiZ; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false,
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false, pow: 1, // no sitter: no put-away
x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
@@ -1505,7 +1607,7 @@ test('rally depth: deep by default, mistiming lands it SHORTER, never longer', (
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = 24; 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, rx: null,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1, rx: null,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests)
@@ -1532,7 +1634,7 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t
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,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
const chase = Math.hypot(zb.vx, zb.vz);
@@ -1543,7 +1645,7 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t
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,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
+13 -1
View File
@@ -84,6 +84,7 @@ const DT = 1 / 60;
const RUNGS = (process.env.RUNGS || '1,6,12,18').split(',').map(Number);
const SECS = Number(process.env.SECS || 240);
TUNE.autoAssist = 1.15;
for (const [k, v] of Object.entries(process.env)) if (k.startsWith('T_')) TUNE[k.slice(2)] = Number(v); // T_punishRead=0.4 etc.
const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0;
const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
// BOT=human (the default): taps on the timed gold ring like a player (JIT ms of timing spread, a
@@ -92,7 +93,7 @@ const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[M
const HUMAN = (process.env.BOT || 'human') === 'human';
const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7;
const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
const probe = [];
const probe = [], kills = []; let kOpen = null, kpow = 1;
const bot = { armed: false, fire: -1, jit: null, swipe: null };
const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n;
return { ax: Math.abs(fx) > 0.38 ? Math.sign(fx) : 0, az: Math.abs(fy) > 0.38 ? Math.sign(fy) : 0, fx, fy, mag }; };
@@ -172,6 +173,16 @@ for (const rung of RUNGS) {
update(DT);
if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; }
if (ball.lastHit !== 'O') G.swipe = null;
// KILL=1: every put-away (a q >= .95 CPU ball straight after a player ball under .5) — where it went,
// how far the player had to go, how long he had, and whether he got it back
if (process.env.KILL) {
if (lh === 'P' && ball.lastHit === 'O' && kpow < 0.5 && ball.pow >= 0.95) {
const L = gp.predictLanding(); kills.push({ px: +P.x.toFixed(1), pz: +P.z.toFixed(1), lx: L && +L.x.toFixed(1), lz: L && +L.z.toFixed(1), tl: L && +L.t.toFixed(2), v: +Math.hypot(ball.vx, ball.vz).toFixed(1), got: null }); kOpen = kills[kills.length - 1];
}
if (kOpen && ball.lastHit === 'P' && lh === 'O') { kOpen.got = +ball.pow.toFixed(2); kOpen = null; }
if (kOpen && (!ball.active || G.phase !== 'rally')) { kOpen.got = 'LOST'; kOpen.gap = +(G.rallyNear ? G.rallyNear.d : -1).toFixed(2); kOpen = null; }
if (ball.lastHit === 'P') kpow = ball.pow;
}
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) {
const ch = ball.lastHit === 'P' ? P : rz;
@@ -221,6 +232,7 @@ for (const rung of RUNGS) {
rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) });
}
if (!process.env.SUMMARY) { console.table(rows); console.table(land); }
if (process.env.KILL) { console.table(kills.slice(0, 30)); const l = kills.filter(k => k.got === 'LOST').length; console.log('put-aways', kills.length, 'won by CPU', l); }
if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); }
if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); }
console.table(pts);