Give every opponent a style, and split the soft ball into drop and lob
Step 2 of the rally rework: eight styles as dials on the opponent rows — pusher, retriever, slicer, big hitter, baseliner, touch, net rusher, all-court — read where the CPU chooses its shot (moonball, drop, lob, spin), plans its approach and serves (serve and volley sprints in). The walk-on names the style in one line so the player can plan against it. A player lobbed at the net now backpedals and blocks, like the CPU. Oliver on v363: the down-flick "always felt like a lob". The length is read at contact and a thumb has usually travelled on; on the continuum even 50 px floated the ball. Below dropMax (90 px) it is now the drop, above it the lob. Also un-flakes the machine-squeak test (seed 31337). v364. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72760a9d1e
commit
180288ef8f
7 files changed
+268
-42
No files matched your search
@@ -1069,12 +1069,16 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
|
||||
// the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate
|
||||
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
||||
try {
|
||||
startMatch(); G.interlude.introT = 0; G.match.opp = 12;
|
||||
// a BASELINER (THE LINE JUDGE): since the styles (step 2) a net rusher volleys on his way in by
|
||||
// design, so the baseline shape is asserted against a body whose game is the baseline
|
||||
startMatch(); G.interlude.introT = 0; G.match.opp = 11;
|
||||
assert.equal(cfg.matchFoe(11).style, 'baseliner', 'the subject plays from the back');
|
||||
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;
|
||||
for (let i = 0; i < 120 * 60; i++) {
|
||||
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; // keep playing: the shape is the subject
|
||||
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'); }
|
||||
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') {
|
||||
@@ -1091,7 +1095,9 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
|
||||
}
|
||||
const back = hits.filter(h => h.back);
|
||||
assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts');
|
||||
assert.ok(back.length / hits.length >= 0.6, `most of the rally is played from the back: ${back.length}/${hits.length}`);
|
||||
// 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;
|
||||
assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`);
|
||||
const gaps = [];
|
||||
@@ -1199,6 +1205,10 @@ 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);
|
||||
// Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob" — a NATURAL short flick (a thumb
|
||||
// travels 40-80 px) must already be the drop, not a floater on the way to a lob
|
||||
const flick = softShot(TUNE.dropMax - 15);
|
||||
assert.ok(Math.abs(flick.depth - drop.depth) < 0.3 && flick.spin === drop.spin, 'a short natural flick is the drop');
|
||||
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)
|
||||
@@ -1252,6 +1262,97 @@ test('a LOBBED net man who runs it down only gets a defensive ball back', () =>
|
||||
} finally { TUNE.matchFlub = flub; }
|
||||
});
|
||||
|
||||
// ---- THE STYLES (step 2, 2026-10-14): how a body plays is a row of dials --------------------------
|
||||
// 400 returns off one body, the player parked at `pz`: what share came back soft, and the spins.
|
||||
function styleReturns(rung, pz, aiZ = 2) {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = rung;
|
||||
const zb = oppOf(); zb.foe = cfg.matchFoe(rung);
|
||||
const out = { n: 0, soft: 0, lob: 0, drop: 0, spins: new Set() };
|
||||
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
|
||||
try {
|
||||
for (let i = 0; i < 400; i++) {
|
||||
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,
|
||||
x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
||||
update(DT);
|
||||
if (ball.lastHit !== 'O') continue;
|
||||
out.n++;
|
||||
if (ball.soft) { out.soft++; if (ball.spin < 1) out.drop++; else out.lob++; }
|
||||
else out.spins.add(Math.round(ball.spin * 100) / 100);
|
||||
ball.active = false;
|
||||
}
|
||||
} finally { TUNE.matchFlub = flub; }
|
||||
return out;
|
||||
}
|
||||
|
||||
test('THE STYLES: every opponent has one, and the walk-on says which', () => {
|
||||
for (let n = 1; n <= cfg.LADDER; n++) {
|
||||
const f = cfg.matchFoe(n), st = cfg.STYLES[f.style];
|
||||
assert.ok(st, `${f.short} plays a known style (${f.style})`);
|
||||
assert.ok(st.tag.length <= 38, `${f.style}'s tag fits the modal's one line: ${st.tag}`);
|
||||
}
|
||||
startMatch();
|
||||
const m = G.modal || G.modalQ.find(q => q.foe);
|
||||
assert.ok(!m || !m.foe || m.foe.style === cfg.STYLES[cfg.matchFoe(1).style].tag, 'the walk-on carries the tag');
|
||||
});
|
||||
|
||||
test('a PUSHER moonballs as a habit and lobs a man at the net; a BIG HITTER carries topspin', () => {
|
||||
const pusher = cfg.STYLES[cfg.matchFoe(1).style];
|
||||
assert.equal(cfg.matchFoe(1).style, 'pusher');
|
||||
const back = styleReturns(1, 24);
|
||||
assert.ok(Math.abs(back.soft / back.n - pusher.loft) < 0.08, `moonballs ${back.soft}/${back.n} vs loft ${pusher.loft}`);
|
||||
assert.equal(back.drop, 0, 'a pusher does not drop');
|
||||
const net = styleReturns(1, COURT.netZ + TUNE.netStand);
|
||||
assert.ok(Math.abs(net.lob / net.n - pusher.lob) < 0.08, `lobs at the net man ${net.lob}/${net.n} vs ${pusher.lob}`);
|
||||
const power = styleReturns(5, 24);
|
||||
assert.equal(cfg.matchFoe(5).style, 'power');
|
||||
assert.deepEqual([...power.spins], [cfg.STYLES.power.spin], 'every big-hitter rally ball kicks');
|
||||
assert.equal(power.soft, 0, 'and none of them floats');
|
||||
});
|
||||
|
||||
test('a TOUCH player drops you from inside when you hang back — and never from his own baseline', () => {
|
||||
assert.equal(cfg.matchFoe(7).style, 'touch');
|
||||
const inside = styleReturns(7, COURT.L + 0.5, 4), deep = styleReturns(7, COURT.L + 0.5, -0.5);
|
||||
assert.ok(Math.abs(inside.drop / inside.n - cfg.STYLES.touch.drop) < 0.08, `drops from inside ${inside.drop}/${inside.n}`);
|
||||
assert.equal(deep.drop, 0, 'from behind his own line a drop is no option');
|
||||
});
|
||||
|
||||
test('a NET RUSHER serves and volleys: the serve is his approach and he sprints in behind it', () => {
|
||||
assert.equal(cfg.matchFoe(15).style, 'rusher');
|
||||
startMatch(); G.interlude.introT = 0; G.match.opp = 15;
|
||||
const zb = oppOf(); zb.foe = cfg.matchFoe(15);
|
||||
score.server = 'O'; setupPoint();
|
||||
Object.assign(zb, { x: 1.3, z: -0.5, serveHold: 0 });
|
||||
// the serve's flight plus the return's: ~1.1 s — what a real serve-and-volleyer has to close in
|
||||
let served = -1, z0 = null, appr = null, zMax = -99;
|
||||
for (let i = 0; i < 4 * 60 && !(served >= 0 && i - served > 66); i++) {
|
||||
G.modal = null; G.modalQ.length = 0;
|
||||
update(DT);
|
||||
if (served < 0 && ball.active && ball.lastHit === 'O' && G.phase === 'rally') { served = i; z0 = zb.z; appr = ball.approach; }
|
||||
if (served >= 0) zMax = Math.max(zMax, zb.z);
|
||||
}
|
||||
assert.ok(served >= 0, 'he served');
|
||||
assert.equal(appr, 'O', 'the serve is an approach');
|
||||
assert.ok(zMax > z0 + 2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`);
|
||||
});
|
||||
|
||||
test('LOBBED at the net, the player backpedals too, and only blocks it back', () => {
|
||||
freshSplatter(); startMatch(); G.interlude.introT = 0;
|
||||
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
|
||||
try {
|
||||
const zb = oppOf(); zb.swingCool = 999;
|
||||
G.phase = 'rally'; G.wasServe = false;
|
||||
Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 });
|
||||
const v = solveShot(0, 1, 2, 0, COURT.L - 2, 9);
|
||||
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true,
|
||||
x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
|
||||
update(DT);
|
||||
assert.ok(ball.rx && ball.rx.lob && !ball.rx.net, 'the plan turns: it is a lob, he runs it down');
|
||||
} finally { TUNE.autoAssist = assist; }
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1266,7 +1367,7 @@ test('rally depth: deep by default, mistiming lands it SHORTER, never longer', (
|
||||
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null,
|
||||
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
|
||||
update(DT);
|
||||
if (ball.lastHit !== 'O') continue;
|
||||
if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests)
|
||||
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
|
||||
depths.push(COURT.L - (ball.z + ball.vz * t));
|
||||
ball.active = false;
|
||||
@@ -1294,7 +1395,7 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t
|
||||
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;
|
||||
let hits = 0, pace = 0, paced = 0, width = 0;
|
||||
const seed = _seed;
|
||||
_seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
@@ -1305,13 +1406,14 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t
|
||||
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);
|
||||
// a style's soft ball takes its pace from its arc on purpose, so the pace dial reads rally balls
|
||||
hits++; if (!ball.soft) { pace += Math.hypot(ball.vx, ball.vz); paced++; }
|
||||
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 };
|
||||
return { chase, hits, pace: pace / paced, width };
|
||||
};
|
||||
let previous = 0;
|
||||
for (let n = 1; n <= 8; n++) {
|
||||
@@ -2360,7 +2462,9 @@ test('the machine squeaks, fire crackles and catches, the gun cracks, the boss s
|
||||
audio.sfxTest.log = [];
|
||||
spawnZombie(0, 8, 'm');
|
||||
const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99;
|
||||
for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { update(DT); G.hp = 9; }
|
||||
// keep it ROLLING for the whole window: one squeak per grind tick is a 60% roll, and a single 3 m
|
||||
// trip is only ~5 ticks — about 1 seed in 100 heard nothing (seed 31337 found it)
|
||||
for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { if (m.rollTo === undefined) m.rollTo = m.x > 0 ? -3 : 3; update(DT); G.hp = 9; }
|
||||
assert.ok(heard('squeak'), 'a rolling machine announces itself');
|
||||
|
||||
freshSplatter();
|
||||
|
||||
Reference in new issue
Block a user