Make the down-flick the soft ball: a short one drops, a long one lobs

Step 3 of the rally rework, Oliver's pick: in rally modes the down-flick
is soft, always, and its length is how far — a dying drop just over the
net at one end, an attacking lob over a net man at the other. One gesture,
one meaning; not the context-armed lob that was cut.

Ballistics from the cut lob (pace derived from the apex); backspin lives
on ball.spin and every bounce planner reads it, netplay sends it. Against
the CPU it was returned ~100% until the real-tennis reasons went in: soft
balls are read late (disguise), a net man reacts to a pass and backpedals
from a lob, and a lobbed or dug-out reply is a weak block. Four new tests;
v363.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:17:25 +02:00
1 parent f9b0dd2efb
commit 72760a9d1e
7 files changed
+215 -28

No files matched your search

+84 -1
View File
@@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -1169,6 +1169,89 @@ test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still
} finally { TUNE.autoAssist = assist; }
});
// ---- THE SOFT BALL (step 3, 2026-10-14): the down-flick is soft, its length is how far ----------------
// One player shot, struck from the baseline with the swing clock put exactly where trySwing scores it:
// q = 1 at the window's centre, `late` seconds off it for a mistimed one. The opponent is parked out of
// play so the flight is the shot's own. Returns where it landed, how high it flew, its spin, and the
// gap between its first and second bounce.
function softShot(mag, late = 0, rzZ = -0.6) {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 3, z: rzZ, swingCool: 999, swingT: -1 });
G.phase = 'rally'; G.wasServe = false;
Object.assign(P, { x: 0, z: 24, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT + late });
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false },
spin: 1, soft: false, x: 0.4, y: 1.0, z: 23.6, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity });
G.swipe = { ax: 0, az: 1, fx: 0, fy: 1, mag };
update(DT); G.swipe = null;
assert.equal(ball.lastHit, 'P', 'the swing connects');
const q = ball.pow, spin = ball.spin, land = predictLanding();
let apex = ball.y, first = null, atNetMan = null;
const netMan = COURT.netZ - TUNE.netStand;
for (let i = 0; i < 6 * 60 && ball.active && ball.bounces < 2; i++) {
update(DT); apex = Math.max(apex, ball.y);
if (atNetMan === null && ball.z <= netMan) atNetMan = ball.y;
if (ball.bounces === 1 && !first) first = ball.z;
}
return { q, spin, depth: land.z, apex, atNetMan, roll: first === null ? null : Math.abs(ball.z - first), rz };
}
const winLevelNow = () => _winLevel();
test('THE SOFT BALL: a short down-flick is a dying drop, a long one an attacking lob over the net man', () => {
const drop = softShot(TUNE.flickMin + 2), lob = softShot(TUNE.flickFull + 20);
assert.ok(drop.q > 0.95 && lob.q > 0.95, 'both struck flush');
assert.ok(Math.abs(drop.depth - (COURT.netZ - TUNE.dropNet)) < 0.7, 'the drop lands just over the net: z ' + drop.depth.toFixed(2));
// (dropApex is the ask; from the baseline the net-clearance loop lofts it a little, which is right)
assert.ok(drop.apex < lob.apex - 1.5, `on a low arc: ${drop.apex.toFixed(2)} m against the lob's ${lob.apex.toFixed(2)}`);
assert.ok(drop.spin < 0.5 && drop.roll < 2.5, `and it DIES: spin ${drop.spin.toFixed(2)}, ${drop.roll.toFixed(2)} m between its bounces`);
assert.ok(Math.abs(lob.depth - TUNE.lobDeep) < 0.8, 'the lob lands deep: z ' + lob.depth.toFixed(2));
assert.ok(lob.apex > TUNE.lobApex - 0.6 && lob.spin === 1, 'high, and with no backspin: ' + lob.apex.toFixed(2) + ' m');
// ...over a man at the net: where he stands it must be out of his racket's reach
assert.ok(lob.atNetMan > CONTACT.maxY + 0.5, `over the net man's racket: ${lob.atNetMan.toFixed(2)} m at his depth`);
});
test('a mistimed soft ball drifts toward the sitter — the easy ball, never an out', () => {
const clean = softShot(TUNE.flickFull + 20), rushed = softShot(TUNE.flickFull + 20, 0.07);
assert.ok(rushed.q < 0.3, 'the rushed one really is mistimed: q ' + rushed.q.toFixed(2));
assert.ok(Math.abs(rushed.depth - TUNE.softSitter) < Math.abs(clean.depth - TUNE.softSitter),
`it falls short of the corner: z ${clean.depth.toFixed(2)} -> ${rushed.depth.toFixed(2)}`);
assert.ok(rushed.depth > 0 && rushed.depth < COURT.netZ, 'and stays in the court');
});
test('a soft ball is DISGUISED: the opponent stands reading it, but runs at once for a drive', () => {
const moved = (soft) => {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 0, z: -0.6, swingCool: 999, swingT: -1 });
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 0;
const v = solveShot(0, 1, 23, 2.5, soft ? 9.5 : 3, soft ? 12 : 25);
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: soft ? 0.3 : 1, soft,
x: 0, y: 1, z: 23, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
for (let i = 0; i < Math.round((TUNE.softRead - 0.05) * 60); i++) update(DT);
return Math.hypot(rz.x, rz.z + 0.6);
};
assert.ok(moved(false) > 0.5, 'a drive: he is already on his way');
assert.ok(moved(true) < 0.05, 'a drop: he has not read it yet');
});
test('a LOBBED net man who runs it down only gets a defensive ball back', () => {
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
try {
for (const lob of [false, true]) {
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = 18;
const rz = oppOf();
Object.assign(rz, { x: 0, z: 3, swingCool: 0, swingT: -1, foe: cfg.matchFoe(18) });
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 1;
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 1, spin: 1, soft: lob,
rx: { net: false, short: false, lob }, x: 0, y: 1.2, z: 3.6, vx: 0, vy: -1, vz: -4, g: TUNE.gravity });
update(DT);
assert.equal(ball.lastHit, 'O', 'he gets to it');
if (lob) assert.ok(ball.pow <= TUNE.lowVolleyQ + 1e-9, 'run down from a lob: q ' + ball.pow.toFixed(2));
else assert.ok(ball.pow > TUNE.lowVolleyQ, 'the same ball unlobbed is a full reply: q ' + ball.pow.toFixed(2));
}
} finally { TUNE.matchFlub = flub; }
});
test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => {
// the only out a rally ball may find is wide — depth must never put one past the baseline
startMatch(); G.interlude.introT = 0;