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:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:25:52 +02:00
1 parent 72760a9d1e
commit 180288ef8f
7 files changed
+268 -42

No files matched your search

+64 -2
View File
@@ -5323,14 +5323,76 @@ the hook for step 2's opponent.
- **Not done:** a lesson for the gesture (the ghost hand only teaches the side flick), and a "LOB"/"DROP" - **Not done:** a lesson for the gesture (the ghost hand only teaches the side flick), and a "LOB"/"DROP"
word at contact. The arc says it. Add one if the phone test shows nobody finds it. word at contact. The arc says it. Add one if the phone test shows nobody finds it.
### v363's phone verdict: the drop never came (2026-10-14, fixed in v364)
Oliver: *"in touch nach unten erzeugt gefühlt immer einen Lob.. mir ist ein Stoppball nicht gelungen..
vielleicht ist die Geste zu subtil."* Two reasons stacked. The swing fires at `flickMin` (28 px) but the
length is read at CONTACT, by when the thumb has usually travelled on. And on the continuum even a 50 px
flick already floated the ball to mid-court. **Now it is two shots:** a down-flick shorter than
`dropMax` (90 px) is the drop, longer is the lob. The continuum survives only for the opponent's
moonball (`aim.soft` 0.85). Verified with real touch events: 50 px and 75 px give drops, 150 px a lob.
Pinned: a 75 px flick lands exactly where the shortest drop does. A slow, long swipe that is still
short of 90 px at contact is still a drop, so "long and brisk" is the lob gesture.
### Step 2: OPPONENTS WITH A GAME (2026-10-14, v364)
`config.STYLES`: eight styles as dials, one `style` per OPPONENTS row, and the walk-on carries the
style's `tag` as one bold row ("PUSHER — IT ALL COMES BACK HIGH"), because the player has to know what
he faces to plan against it. Dials:
- `loft`: a moonball habit, the soft ball at 0.85.
- `drop`: only when the player stands behind his line AND the body is 1.5 m inside its own.
- `lob`: when the player is at the net.
- `spin`: rally-ball bounce, >1 topspin kick, <1 slice.
- `charge`: lowers its short-ball line.
- `sv`: serve and volley.
Context-chosen shots are fine for the CPU; the one-gesture rule binds the player's thumb.
| style | who | does |
|---|---|---|
| pusher | Shambler, Punter, Steward | 45% moonballs, lobs a net man half the time |
| retriever | Ball Boy, Runner | some moonballs, lobs |
| slicer | Keeper, Seller | low ball (spin 0.8), the odd drop |
| power | Roller, Stringer, Brute | heavy topspin kick (1.15) |
| baseliner | Line Judge | mild topspin, the odd lob |
| touch | Mascot, Physio | drops you when you hang back, lobs you at the net |
| rusher | Umpire, Club Pro, Director | serve and volley, comes in 1.6 m earlier |
| allcourt | Head Coach, Champion | a little of everything |
Wiring:
- **Serve and volley:** `hitServe` stamps `ball.approach = 'O'`. `rz.rushing` is set while its own ball
flies, and the recovery SPRINTS instead of walking. `rxPlan` plays the return as a net man wherever
the run has got to (never a serve return), and net mode heads to `netStand` depth on the ball's line,
with `netReact` as the split step.
- **The player lobbed at the net** gets the CPU's rule: `intercept` flips his plan to `lob`, his
backpedal runs at `lobTurn`, and his reply is capped.
- `rzChaseSpeed`'s lob factor reads only the CPU's OWN plan.
Tests (160, ten seeds):
- every rung styled, tags fit
- pusher moonball and lob shares match the table, and it never drops
- big-hitter balls all kick
- touch drops from inside, never from behind its line
- the rusher charges more than 2 m behind its serve
- the player's plan turns to lob
The step-1 baseline-shape test now plays THE LINE JUDGE (a baseliner, `oppOf().foe` set; the body plays
the style, not the rung number), and its net share floor is 50%. The autopilot mistimes into short balls,
and a body is meant to come in on those. The back-court volley cap is the real guard. The depth and Climb
pace samplers skip soft balls, since a moonball's pace comes from its arc. Also fixed in passing: the
machine-squeak test was ~1% seed-fragile (one 3 m roll, about 5 grind ticks at 60%). It now keeps
rolling (seed 31337 found it).
**Not built:** the champion LEARNING whether the net works for him (Free Tennis's idea), a style for
classic's CPU, and a "DROP"/"LOB" word at contact.
# 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.
0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converged on serve, both at the net, 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 reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
phone verdict. Next up: step 3 (the soft ball, approved), step 2 (opponent styles), and step 4 (the legs: phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the
the Champion runs 15.7 m/s). See the section of that name above. Fallback tag `rally-baseline-v360`. opponent styles (v364) followed. Awaiting the phone verdict on v364. See the section of that name
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 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. 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>
</div> </div>
<span id="recLamp" hidden>REC</span> <span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=363"></script> <script type="module" src="src/main.js?v=364"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -7,7 +7,7 @@
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as // (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.) // input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js'; import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js';
import { endRun, MODE_MATCH } from './stats.js'; import { endRun, MODE_MATCH } from './stats.js';
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.js'; import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
@@ -685,7 +685,7 @@ function matchOpponent() {
showModal({ title: zb.foe.name, color: UI.hot, showModal({ title: zb.foe.name, color: UI.hot,
body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub), body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub),
foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line, foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line,
lvl, mine, peer, sub } }); lvl, mine, peer, sub, style: STYLES[zb.foe.style]?.tag } });
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
+42 -18
View File
@@ -94,6 +94,7 @@ export const TUNE = {
lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up
scoopY: 0.45, // m: a ball met lower than this after its bounce is dug out, same cap scoopY: 0.45, // m: a ball met lower than this after its bounce is dug out, same cap
// THE SOFT BALL (step 3): the down-flick, its length = how far. Drop at one end, lob at the other. // THE SOFT BALL (step 3): the down-flick, its length = how far. Drop at one end, lob at the other.
dropMax: 90, // px: a down-flick shorter than this is the DROP, this long or longer the LOB
dropNet: 2.0, // m past the net a shortest down-flick lands dropNet: 2.0, // m past the net a shortest down-flick lands
dropApex: 2.0, // m its arc peaks at dropApex: 2.0, // m its arc peaks at
dropSpin: 0.3, // share of its pace a drop keeps through the bounce (backspin: it dies) dropSpin: 0.3, // share of its pace a drop keeps through the bounce (backspin: it dies)
@@ -1220,54 +1221,77 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
// a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the // a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the
// ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled // ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled
// off the body it already has in the gore mode, same rule as the twelve before it. // off the body it already has in the gore mode, same rule as the twelve before it.
// THE STYLES (step 2 of the rally rework, 2026-10-14 — PROJECT.md "THE RALLY GOES FLAT"). How a body
// PLAYS, as dials, so a style is data and never a branch. Read in trySwing (what it does with the
// ball), rxPlan (when it comes in), racketAI (serve and volley) and doHit (the spin it carries).
// Context-chosen shots are fine HERE — the one-gesture rule binds the player's thumb, and a lob or
// a drop announces itself in its own arc.
// loft share of rally balls sent back as a high deep MOONBALL (the soft ball's long end)
// drop chance of a DROP when it stands inside its own baseline and you stand behind yours
// lob chance of a LOB when you stand at the net
// spin the bounce its rally balls carry: >1 heavy topspin (kicks up and on), <1 slice (low)
// charge m it lowers its short-ball line: it comes in on balls a baseliner would stay back for
// sv serves and volleys
// tag the walk-on's one line about it — the player has to know what he is facing to plan
export const STYLES = {
pusher: { loft: 0.45, lob: 0.5, tag: 'PUSHER — IT ALL COMES BACK HIGH' },
retriever: { loft: 0.15, lob: 0.35, tag: 'RETRIEVER — RUNS DOWN EVERYTHING' },
slicer: { spin: 0.8, drop: 0.25, tag: 'SLICER — LOW BALLS THAT STAY DOWN' },
power: { spin: 1.15, tag: 'BIG HITTER — HEAVY TOPSPIN' },
baseliner: { spin: 1.07, lob: 0.25, tag: 'BASELINER — PAINTS THE LINES' },
touch: { drop: 0.45, lob: 0.55, tag: 'TOUCH — DROPS YOU WHEN YOU HANG BACK' },
rusher: { charge: 1.6, sv: true, tag: 'NET RUSHER — PASS HIM OR LOB HIM' },
allcourt: { loft: 0.08, drop: 0.2, lob: 0.4, spin: 1.07, charge: 0.8, tag: 'ALL-COURT — A BIT OF EVERYTHING' },
};
const OPPONENTS = [ const OPPONENTS = [
{ short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh', { short: 'SHAMBLER', style: 'pusher', name: 'THE SHAMBLER', fam: 'sh',
line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 }, line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 },
{ short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb', { short: 'BALL BOY', style: 'retriever', name: 'THE BALL BOY', fam: 'bb',
line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 }, line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 },
{ short: 'PUNTER', name: 'THE SPECTATOR', fam: 'sp', { short: 'PUNTER', style: 'pusher', name: 'THE SPECTATOR', fam: 'sp',
line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 }, line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 },
{ short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk', { short: 'KEEPER', style: 'slicer', name: 'THE GROUNDSKEEPER', fam: 'gk',
line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 }, line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 },
// 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard // 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard
{ short: 'ROLLER', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95, { short: 'ROLLER', style: 'power', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95,
line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 }, line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 },
{ short: 'STEWARD', name: 'THE STEWARD', fam: 'st', { short: 'STEWARD', style: 'pusher', name: 'THE STEWARD', fam: 'st',
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 }, line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 },
// 7-8: the entertainment, and then the legs // 7-8: the entertainment, and then the legs
{ short: 'MASCOT', name: 'THE MASCOT', fam: 'ma', { short: 'MASCOT', style: 'touch', name: 'THE MASCOT', fam: 'ma',
line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 }, line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 },
{ short: 'RUNNER', name: 'THE RUNNER', fam: 'ru', { short: 'RUNNER', style: 'retriever', name: 'THE RUNNER', fam: 'ru',
line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 }, line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 },
// 9-12: the trades and the officials — width arrives here // 9-12: the trades and the officials — width arrives here
{ short: 'SELLER', name: 'THE STRAWBERRY SELLER', fam: 'sb', { short: 'SELLER', style: 'slicer', name: 'THE STRAWBERRY SELLER', fam: 'sb',
line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 }, line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 },
{ short: 'STRINGER', name: 'THE STRINGER', fam: 'sg', { short: 'STRINGER', style: 'power', name: 'THE STRINGER', fam: 'sg',
line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 }, line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 },
{ short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj', { short: 'JUDGE', style: 'baseliner', name: 'THE LINE JUDGE', fam: 'lj',
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 }, line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 },
{ short: 'UMPIRE', name: 'THE CHAIR UMPIRE', fam: 'cu', { short: 'UMPIRE', style: 'rusher', name: 'THE CHAIR UMPIRE', fam: 'cu',
line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 }, line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 },
{ short: 'PHYSIO', name: 'THE PHYSIO', fam: 'ph', { short: 'PHYSIO', style: 'touch', name: 'THE PHYSIO', fam: 'ph',
line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 }, line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 },
{ short: 'COACH', name: 'THE HEAD COACH', fam: 'hc', { short: 'COACH', style: 'allcourt', name: 'THE HEAD COACH', fam: 'hc',
line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 }, line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 },
{ short: 'CLUB PRO', name: 'THE CLUB PRO', { short: 'CLUB PRO', style: 'rusher', name: 'THE CLUB PRO',
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 bands. 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', style: 'power', 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.50, qMin: 0.62, qMax: 0.76 }, 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', style: 'rusher', 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.70, qMin: 0.68, qMax: 0.83 }, 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', style: 'allcourt', 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.85, qMin: 0.74, qMax: 0.90 }, 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 },
]; ];
+45 -11
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
@@ -255,12 +255,16 @@ function doHit(ch, quality, aim) {
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next). // depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
// THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always, // THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always,
// and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the // and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the
// back corner, the space between a continuum of floaters. One gesture, one meaning, whoever is // back corner (the opponent's moonball uses the space between). One gesture, one meaning, whoever is
// where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward // where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward
// softSitter — a drop that sits up, a lob that falls short: the easy ball either way. // softSitter — a drop that sits up, a lob that falls short: the easy ball either way.
// `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob. // `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob.
// TWO shots, not a continuum (Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob ... mir
// ist ein Stoppball nicht gelungen"). The swing fires at flickMin and the length is read at
// contact, by when a thumb has usually travelled on — and on a continuum even 50 px already
// floated the ball to mid-court. Below dropMax it is the drop, at or past it the lob.
soft = rallyFeel() && aim.az > 0 soft = rallyFeel() && aim.az > 0
? (aim.soft ?? (aim.mag === undefined ? 1 : Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin))))) ? (aim.soft ?? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? 1 : 0))
: null; : null;
if (soft !== null) { if (soft !== null) {
const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline
@@ -354,7 +358,7 @@ function doHit(ch, quality, aim) {
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
// a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob // a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob
ball.spin = soft === null ? 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2); ball.spin = soft === null ? (aim && aim.spin) || 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2);
ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead) ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead)
// ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies). // ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies).
// Never against a man already at the net — that ball is a PASS, and two volleyers is the old // Never against a man already at the net — that ball is a PASS, and two volleyers is the old
@@ -577,7 +581,9 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp); perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.approach = null; ball.spin = 1; ball.soft = false; ball.from = Math.abs(sv.z - COURT.netZ); ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; 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; ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts ball.kills = 0; // fresh serve: multi-kill streak restarts
ball.praise = null; // ...and any word a spent ball never got to pop dies with it ball.praise = null; // ...and any word a spent ball never got to pop dies with it
@@ -1251,7 +1257,7 @@ const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace()
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1) * (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1)
// ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is // ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is
// what makes the lob the answer to the net (the planner believes it too) // what makes the lob the answer to the net (the planner believes it too)
* (ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); * (ball.lastHit === 'P' && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // HIS plan, not the player's
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from // Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own // the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
@@ -1411,10 +1417,16 @@ function rxPlan(ch) {
if (!ball.rx) { if (!ball.rx) {
const land = ball.bounces === 0 ? predictLanding() : null; const land = ball.bounces === 0 ? predictLanding() : null;
const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0; const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0;
ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom, short: !G.wasServe && depth > TUNE.shortBall }; const st = styleOf(ch);
// a body still RUSHING the net (serve and volley, a charge behind its approach) plays it as a
// net man from wherever its run has got to — a serve return never counts
ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || !!(ch.rushing && !G.wasServe),
short: !G.wasServe && depth > TUNE.shortBall - (st && st.charge || 0) };
} }
return ball.rx; return ball.rx;
} }
// THE STYLES (step 2): how this body plays — the solo match's opponents only (see config.STYLES)
const styleOf = ch => (G.match && !G.net && ch && ch.foe && STYLES[ch.foe.style]) || null;
// ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape // ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape
const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net; const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net;
// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back // ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back
@@ -1452,7 +1464,13 @@ function intercept(side) {
const g = ball.g || TUNE.gravity, dt = 0.01; const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = rallyFeel() ? rallyHome(ch) : HOME[side]; const home = rallyFeel() ? rallyHome(ch) : HOME[side];
const plan = rallyFeel() ? rxPlan(ch) : null, back = plan && !plan.net; // a baseliner lets it bounce const plan = rallyFeel() ? rxPlan(ch) : null;
// a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule)
if (plan && plan.net && (ball.z - ch.z) * vz < 0) {
const tc = (ch.z - z) / vz;
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
}
const back = plan && !plan.net; // a baseliner lets it bounce
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces; let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
for (let i = 0; i < 300; i++) { for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
@@ -1580,6 +1598,18 @@ 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) };
// 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;
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;
if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; }
else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; }
else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; }
else aim.spin = st.spin;
}
// A quality floor makes strong opponents sustain pressure, instead of only improving their // A quality floor makes strong opponents sustain pressure, instead of only improving their
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor. // 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; const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
@@ -1612,6 +1642,8 @@ function racketAI(rz, dt) {
const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored
&& !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0 && !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0
&& !(rz.serveHold > 0); && !(rz.serveHold > 0);
// ...and while its own ball flies, is it RUSHING the net behind it? (serve and volley, an approach)
if (recovering) rz.rushing = ball.approach === 'O';
// ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on // ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on
// the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben // the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben
// die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue. // die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue.
@@ -1660,7 +1692,7 @@ function racketAI(rz, dt) {
const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x; const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x;
// Read the landing depth immediately in a match, including rising returns. Blending with // Read the landing depth immediately in a match, including rising returns. Blending with
// the ball's current depth would send every receiver towards the net before it arrives. // the ball's current depth would send every receiver towards the net before it arrives.
const aimZ = recovering ? home.z : c ? c.z : netX !== null ? rz.z const aimZ = recovering ? home.z : c ? c.z : netX !== null ? Math.max(rz.z, COURT.netZ - TUNE.netStand)
: pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z; : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ)); const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
@@ -1670,7 +1702,8 @@ function racketAI(rz, dt) {
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a // 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
// whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase. // whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase.
// (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share) // (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share)
const step = Math.min(rzChaseSpeed(rz) * (recovering ? 0.5 : 1) * dt, d); // (recovery walks at half pace — but a body charging the net behind its shot SPRINTS)
const step = Math.min(rzChaseSpeed(rz) * (recovering && !rz.rushing ? 0.5 : 1) * dt, d);
rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
if (rz.pose !== 'swing') rz.pose = 'run'; if (rz.pose !== 'swing') rz.pose = 'run';
@@ -2106,7 +2139,8 @@ export function update(dt) {
// included. The assist puts you there; the volley timing stays honest. // included. The assist puts you there; the volley timing stays honest.
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); 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 // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55); // ...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);
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists // Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
// for a reason: // for a reason:
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid // GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
+2
View File
@@ -2369,6 +2369,8 @@ function modalRows(m, inner, skipBody) {
const push = r => { rows.push(r); return r; }; const push = r => { rows.push(r); return r; };
if (m.foe) { if (m.foe) {
push({ kind: 'split', h: m.foe.peer ? 38 : 26 }); push({ kind: 'split', h: m.foe.peer ? 38 : 26 });
// HOW IT PLAYS (the styles, step 2): one bold line, because a plan needs to know what it faces
if (m.foe.style) { const lead = wrapText(m.foe.style, F(8, 700), inner); push({ kind: 'rule', lead, h: lead.length * 12 + 4 }); }
const sub = []; const sub = [];
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 }); if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
+112 -8
View File
@@ -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 // the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try { 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); for (let i = 0; i < 2.2 * 60; i++) update(DT);
const hits = []; const hits = [];
let prev = ball.lastHit; let prev = ball.lastHit;
for (let i = 0; i < 120 * 60; i++) { 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'); } 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 === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { 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); const back = hits.filter(h => h.back);
assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts'); 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; 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`); assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`);
const gaps = []; 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', () => { 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); 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(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)); 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) // (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; } } 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', () => { 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 // the only out a rally ball may find is wide — depth must never put one past the baseline
startMatch(); G.interlude.introT = 0; 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, 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 }); x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT); 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; 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)); depths.push(COURT.L - (ball.z + ball.vz * t));
ball.active = false; 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 }); x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT); update(DT);
const chase = Math.hypot(zb.vx, zb.vz); 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; const seed = _seed;
_seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls _seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls
for (let i = 0; i < 1200; i++) { 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 }); x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT); update(DT);
if (ball.lastHit !== 'O') continue; 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; 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)); width = Math.max(width, Math.abs(ball.x + ball.vx * t));
ball.active = false; ball.active = false;
} }
_seed = seed; _seed = seed;
return { chase, hits, pace: pace / hits, width }; return { chase, hits, pace: pace / paced, width };
}; };
let previous = 0; let previous = 0;
for (let n = 1; n <= 8; n++) { 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 = []; audio.sfxTest.log = [];
spawnZombie(0, 8, 'm'); spawnZombie(0, 8, 'm');
const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99; 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'); assert.ok(heard('squeak'), 'a rolling machine announces itself');
freshSplatter(); freshSplatter();