The staff learn your habits, the walk-on waits for your tap, and the club opens a practice court

Four answers to one phone report:
- THE READ: repeat a lane and the opponent anticipates it; break the habit
  and it is wrong-footed for that ball — scaled by rung, so surprise is the
  weapon that opens the ladder past the HEAD COACH (TUNE block THE READ).
- The opponent walk-on is a real modal now (head, punchline, tap to dismiss)
  in arcade, tournament and training — readable without a clock.
- THE TRAINING COURT: third card under ZOMBIE TENNIS, pick any rung, one
  game at a time, the felled body walks straight back on, nothing banks,
  bleeding out is a popup instead of a death. mode_train.png rolled in the
  banner style.
- MOVEMENT READS: sprinting feet kick per-court dust and fast bodies leave
  a two-ghost speed streak (trailMin gates both; feel verdict pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-28 16:08:18 +02:00
1 parent a42dc6fc81
commit 43784c5b33
9 files changed
+360 -43

No files matched your search

+65
View File
@@ -491,6 +491,71 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE READ, the walk-on modal, the training court, movement reads (2026-08-28)
Four items from one phone report. PHONE VERDICT PENDING on all of them.
### 1. THE READ — the twist for the post-COACH wall
Oliver: past the HEAD COACH *"the opponents are very fast and barely make mistakes any more — we
need a twist, maybe a kind of cognitive fatigue when you make the same move ten times and then
surprise them."* Built as HABITUATION, his own idea with the numbers made playable:
- **doHit buckets every aimed player rally shot into three lanes** (target x at ±1.1) and keeps a
same-lane run on `G.match.laneRun`. `TUNE.readHabit` (3) same-lane shots = the habit is learned.
- **One more of the same lane is `expected`**: the opponent's `aiRead` climbs toward
`TUNE.readAnticipate` (0.97) — spam feeds the machine, so the late ladder punishes one-note play.
- **Breaking the habit is `surprised`, for that one flight**: the read collapses (`surpRead`), the
chase speed drops (`surpSlow`, applied inside `rzChaseSpeed` so the intercept planner and the
early-fall test believe it too), and `surpFlub` lands on top of a whiff rate the champion had
reduced to almost nothing. A "WRONG-FOOTED." flash announces it (rungs past ~20% only).
- **Everything scales with the rung** (`learnK`, (opp−1)/(LADDER−1)): the shambler never learns,
the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and
breaking it on the big point. Solo match only (netplay's opponent is a human), rally lanes only
(serves keep their own game — open: should serve placement feed the same book?). The one-ball
states are cleared at the racket and at every point end (`matchReady`); a fresh walk-on resets
the book. Tests pin the state machine (real swings through `keyPressed`) and the dial
relationships (a wrong-footed champion runs slower than the head coach at full stride).
### 2. THE WALK-ON IS A MODAL (tournament + arcade + training)
Oliver: *"die Intros der einzelnen Spieler bitte als richtiges Modal, das man wegtappen muss —
man kann dann wirklich lesen was da steht, und schnell wegklicken wenn man es schon 100x gelesen
hat."* `matchOpponent` now calls `showModal` (head icon `z_<fam>head`, the punchline, the cup's
round line) instead of the timed 4 s card. The walk-on court is guaranteed-calm (the mop beat has
just ended), so the popup policy allows it; the freeze holds the 1.6 s intro bar until the tap.
The mode-rules popup fires BEFORE `matchOpponent` in `startMatch` so it lands first and the
opponent queues behind it. Netplay untouched (no walk-on there at all). The test harness's
`passMopBeat` now taps the walk-on away like a player would.
### 3. THE TRAINING COURT (`MODES` id `train`, third kid of ZOMBIE TENNIS)
Oliver: *"ein Trainingsbereich — Auswahl des Gegners, ein Spiel, tap to replay, bis man keine
Lust mehr hat."* One flag (`d.train`, set by `startMatch(false, null, rung)`) on the whole match
machinery:
- SERVE UP routes to a picker screen (`#train`, built from the roster like the boss picker, the
same `z_*head` crops the draw uses); tapping a rung IS the start — tap-advances, like every
other screen. `mode_train.png` rolled in the banner style (golden-hour practice court, ball
basket, cones, no text) via the famroll OpenRouter route.
- The rung is PARKED: `d.opp` never advances, a felled body walks back on as itself, and the
walk-on modal (item 2) is the "tap to replay" gate for free. Between reps the blood resets
silently (no WOUNDS TREATED ceremony — a popup per rep is the tap-tax the mode avoids).
- NOTHING BANKS: no `G.runGames`, no `endRun` (pagehide guarded), and bleeding out is a modal
("IT BLED YOU OUT") plus a fresh pool, never the death screen. The quit chip is the exit.
- The board's progress slot reads `TRAINING · <SHORT>`; the setup footer says "NOTHING AT STAKE".
### 4. MOVEMENT READS — dust and speed ghosts (feel-tunable, verdict pending)
Oliver: *"die Bewegung der Spieler optisch anreichern — motion blur / particles / shader, hier
bin ich auf deinen Input angewiesen."* Two cheap effects in the existing registers, no shaders:
- **Sprint dust**: feet crossing `TUNE.trailMin` (3.6 m/s — above every shamble) kick one
per-court `dustPuff` per `runDustEvery` metres. racketAI's chase and the player's mover
(auto-run, horde steer) both emit; the mover measures its travel AFTER the clamps so a body
pinned at a bound reads as standing.
- **Speed ghosts**: `drawZombie`/`drawChar` draw two alpha ghosts sampled back along the body's
own velocity (`trailK` s × 1 and 2) — the modal masthead's speed-streak trick applied to
bodies. Only genuine sprints qualify; never during a named frame (`showFrame` — a collapsing
corpse must not smear on stale velocity). racketAI now records `vz` beside `vx`; the player's
`P.vx/vz` are written by the mover and zeroed outside it.
Both are panel-tunable (`trailMin` 0 kills both). Not tried, on the shelf if these fail the
phone: a one-frame lean/skid sprite on hard direction changes (needs drawn frames, per the
draw-frames rule), and court-colored skid streaks on the ground behind a hard stop.
## Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day)
### THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+17 -6
View File
@@ -174,7 +174,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs { display: none; }
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
@@ -186,6 +186,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro[data-stage="secret"] #secret,
#intro[data-stage="bosses"] #bosses,
#intro[data-stage="bracket"] #bracket,
#intro[data-stage="train"] #train,
#intro[data-stage="vs"] #vs,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
@@ -264,16 +265,19 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* THE BOSS PICKER (back room). Two columns of portraits, because you pick a boss by looking at
him — the tile is mostly art. Same plate as the tour tiles above; the grid is what makes ten
of them fit on a phone. */
#bossList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
#bossList button { display: flex; flex-direction: column; align-items: center; gap: 5px;
#bossList, #trainList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
#bossList button, #trainList button { display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line);
color: inherit; font: inherit; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
#bossList img { height: 78px; width: auto; display: block; image-rendering: pixelated; }
#bossList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
#bossList b, #trainList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
text-align: center; line-height: 1.35; }
#bossList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
letter-spacing: 1px; }
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
draw and the walk-on wear), which reads at a third of the boss portrait's height */
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
/* THE DRAW. Four rungs read top-down as a ladder, because that is what the mode is — the bracket
shape a real draw is printed in needs width a phone has not got, and half of it would be blank
anyway (you only ever play one side of it). The rows spin their portrait and their name while
@@ -561,6 +565,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button>
</div>
</div>
<!-- THE TRAINING COURT's opponent picker: the whole ladder face-up, tap a body to drill it.
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
<div id="train">
<div class="hdr"><button class="chev" id="trBack">&lsaquo;</button><h2>TRAINING</h2></div>
<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>
<div id="trainList"></div>
</div>
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
the host's one decision, and the friend's code. #vsWait replaces the picker in place once
a socket is open, so the room code and its SHARE button stand where the buttons were and
@@ -664,6 +675,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=308"></script>
<script type="module" src="src/main.js?v=309"></script>
</body>
</html>
+51 -22
View File
@@ -356,9 +356,13 @@ export function watchdog(dt) {
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
// optional and null means "roll each round as it comes", which is what the mode did before the
// screen existed and is still what the #cup back door gets.
export function startMatch(cup = false, draw = null) {
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
// opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a
// felled body walks straight back on, and bleeding out costs a modal instead of the run. All of
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
export function startMatch(cup = false, draw = null, train = 0) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: 1, ending: false };
G.match = { opp: train || 1, ending: false, train: !!train };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
@@ -372,16 +376,19 @@ export function startMatch(cup = false, draw = null) {
zombies.length = 0;
G.boy = null;
score.pts = [0, 0];
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
if (cup && !G.seen.cup) {
// The mode's rules modal fires BEFORE matchOpponent, so it lands FIRST and the opponent's own
// walk-on modal queues behind it (showModal's modalQ keeps call order) — rules, then the body.
if (train && !G.seen.train) {
G.seen.train = true;
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
body: ['ONE BODY, ONE GAME AT A TIME',
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NOTHING IS BANKED HERE'] });
} else if (cup && !G.seen.cup) {
G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
body: ['FOUR ROUNDS UP THE DEAD HIERARCHY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
} else if (!cup && !G.seen.match) {
} else if (!cup && !train && !G.seen.match) {
G.seen.match = true;
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
// number, so the modal can never drift from the pool it describes.
@@ -389,6 +396,10 @@ export function startMatch(cup = false, draw = null) {
body: ['WIN THE POINT — WOUND THE ZOMBIE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
}
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
return true;
}
@@ -418,6 +429,8 @@ export function matchReady() { // exported for net.js: a paired death match re-p
ball.active = false;
G.serveNum = 1;
if (G.match) G.match.decided = false; // the next rally may be decided early again
// ...and THE READ's one-ball states die with the point: a lean only lasts the flight it answered
if (G.match) G.match.surprised = G.match.expected = false;
setupPoint();
const rz = zombies.find(z => z.variant === 'r');
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
@@ -627,13 +640,18 @@ function matchOpponent() {
// so killZombie can file the kill under it without asking G which opponent we were on.
zb.foe = matchFoe(G.match.opp);
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
// of the player should last longer"). In the cup the round names itself on the third line.
// THE WALK-ON IS A MODAL NOW, not a timed card (2026-08-28, Oliver: "als richtiges Modal, das
// man wegtappen muss — you can actually read it without time pressure, and tap it away fast when
// you have read it a hundred times"). The walk-on is a guaranteed-calm court (the mop beat has
// just ended, the intro bar is armed), so the popup policy allows it; update()'s freeze holds the
// 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore.
// In the cup the round names itself on the second line.
const cup = G.match.cup;
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
body: [zb.foe.line].concat(cup ? [CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '')] : []) });
// ...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;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
// game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
@@ -680,10 +698,18 @@ function matchPoint(side) {
// clip over a second long, so every one of these was being swallowed silently.
aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2);
if (G.hp <= 0) {
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
// the other phone turns it into its win screen (netMatchWon)
if (G.net && G.net.ready) netSendDead();
return killPlayer(); // eaten — the run summary, as ever
// THE TRAINING COURT HAS NO DEATH IN IT. Bleeding out costs a modal and a fresh pool — the
// point still goes to the body (advancePoint below), so a game you were losing stays lost.
if (d.train) {
G.hp = G.hpMax;
showModal({ title: 'IT BLED YOU OUT', color: UI.bad,
body: ['THE TRAINER MOPS YOU UP — GO AGAIN', 'NOTHING IS BANKED HERE'] });
} else {
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
// the other phone turns it into its win screen (netMatchWon)
if (G.net && G.net.ready) netSendDead();
return killPlayer(); // eaten — the run summary, as ever
}
}
}
const over = advancePoint(side); // banks the game and resets the points itself
@@ -737,7 +763,7 @@ function matchGameWon(zb, banked = false) {
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++; // the ladder metric, same as every mode
if (!d.train) G.runGames++; // the ladder metric — training banks nothing
voice('game_player', 0.3); cheer(2.4, 0.6);
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
@@ -745,8 +771,9 @@ function matchGameWon(zb, banked = false) {
G.flash = { text: 'GAME.', t: 1.4 };
// netplay: the SAME opponent walks back on — the body across the net is one particular friend,
// and handing them the roster's next name and face would break the fiction (the ladder dials the
// opp number drives are AI-only and dormant here anyway)
if (!G.net) d.opp++;
// opp number drives are AI-only and dormant here anyway). Training parks the ladder the same
// way: the rung you picked is the rung that walks back on, as many times as you want it.
if (!G.net && !d.train) d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
@@ -772,7 +799,9 @@ function matchGameWon(zb, banked = false) {
}
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => {
matchHealBeat();
// training resets the blood WITHOUT the WOUNDS TREATED ceremony — every game starts whole,
// and a popup between every single rep is exactly the tap-tax the mode exists to avoid
if (d.train) G.hp = G.hpMax; else matchHealBeat();
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.
+26 -1
View File
@@ -599,6 +599,28 @@ export const TUNE = {
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
// ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely
// make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same
// lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated
// (its bounce-read climbs toward perfect, so spam feeds the machine), while BREAKING the habit
// wrong-foots it for that one ball: the read collapses, the legs slow, the whiff climbs. Both
// effects scale with the RUNG ((opp-1)/(LADDER-1)), so the shambler never learns anything and
// the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and
// breaking it on the big point. Skill, not grind, is what opens the ladder past the coach.
// Rally lanes only (doHit's aimed player shots); serves keep their own game. Solo match only.
readHabit: 3, // same-lane returns in a row before the opponent has learned the pattern
readAnticipate: 0.97,// what aiRead climbs toward on an EXPECTED ball at the top rung
surpRead: 0.85, // fraction of aiRead a wrong-footed opponent loses (x rung scale)
surpSlow: 0.45, // fraction of chase speed it loses on the surprise ball (x rung scale)
surpFlub: 0.35, // whiff ADDED at contact on the surprise ball at the top rung
// ---- MOVEMENT READS (2026-08-28): the sprites run, the court answers. Two cheap effects, both
// live-tunable: feet kick the surface's own dust once a body crosses trailMin, and a body moving
// that fast leaves a two-ghost speed streak behind it (the modal masthead's own trick, sampled
// back along the velocity). Procedural register only — dust and afterimage, never painted into
// a sprite, and nothing draws at walking pace: trailMin sits above every shamble.
trailMin: 3.6, // m/s of ground speed before a body earns dust + ghosts (0 = both off)
trailK: 0.05, // s each ghost trails behind along the velocity (two samples: 1x and 2x)
runDustEvery: 0.5, // m of ground covered per dust puff while sprinting
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
@@ -765,6 +787,9 @@ export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
// TRAINING (2026-08-28): pick any rung, play it one game at a time, nothing is banked. The same
// match machinery with the ladder parked — see startMatch's train parameter.
{ id: 'train', name: 'TRAINING', sub: 'PICK A BODY · ONE GAME · GO AGAIN', art: 'mode_train' },
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
// promising one: the match already turned, you just arrived late
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
@@ -776,7 +801,7 @@ export const MODES = [
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
export const MENU = [
{ mode: 'classic', diffs: true },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'] },
{ mode: 'gore' },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
+65 -8
View File
@@ -1,6 +1,6 @@
// 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, CUP_ROUNDS } 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, CUP_ROUNDS, LADDER } from './config.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 { haptic } from './haptics.js';
@@ -232,6 +232,21 @@ function doHit(ch, quality, aim) {
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
// THE READ (see config's dial block): the opponent studies where your rally shots GO. Three
// lanes, bucketed off the aimed target — run readHabit of the same lane together and the habit
// is learned: the NEXT same-lane ball is `expected` (anticipated in racketAI), a lane break is
// `surprised` (wrong-footed, for this one flight). Solo match rally shots only: netplay's
// opponent is a human who does his own reading, and serves keep their own game.
if (G.match && !G.net && ch.side === 'P' && aimedLane) {
const d = G.match, lane = tx < -1.1 ? -1 : tx > 1.1 ? 1 : 0;
d.surprised = (d.laneRun || 0) >= TUNE.readHabit && d.lastLane !== undefined && lane !== d.lastLane;
d.expected = (d.laneRun || 0) >= TUNE.readHabit && lane === d.lastLane;
d.laneRun = lane === d.lastLane ? (d.laneRun || 0) + 1 : 1;
d.lastLane = lane;
// the mechanic announces itself the moment it fires — on the rungs young enough to barely
// lean, the flash is the whole lesson for later
if (d.surprised && (d.opp - 1) / (LADDER - 1) > 0.2) G.flash = { text: 'WRONG-FOOTED.', t: 1.0 };
}
if (G.match && ch.side !== 'P') {
// ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left
// at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less
@@ -1119,9 +1134,17 @@ function matchFallWillPlay() {
// could not start in time (Oliver: the first ten opponents can be killed the moment you serve).
// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at
// any return — so a rally is chased at the ordinary ladder speed and nothing else moves.
// How much this body has LEARNED — THE READ's rung scale, 0 at the shambler, 1 at the champion.
// One definition, because the read boost, the wrong-foot slow and the surprise whiff must all
// answer "how smart is this one" identically or the twist pulls in three directions at once.
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
const rzChaseSpeed = rz => TUNE.racketSpeed
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1);
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
// a body that genuinely cannot get there, which is the whole point)
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1);
// 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
@@ -1413,8 +1436,17 @@ function racketAI(rz, dt) {
// being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied
// to every flight — the same lag was there in a rally, just hidden by the shorter distances.
const pred = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket
const aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z;
// THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly
// — spam feeds the machine — and a SURPRISE ball collapses the read back toward the old pure
// pursuit, which is exactly the wrong-footing aiRead exists to permit. Both scale with the rung
// (learnK), so the shambler never learns and the champion is the one you beat by breaking a
// habit you set on purpose. Dials in config's THE READ block; the streak lives in doHit.
const learn = learnK(rz);
const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn)
: G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn
: TUNE.aiRead;
const aimX = pred ? ball.x + (pred.x - ball.x) * read : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * read : ball.z;
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) {
@@ -1434,7 +1466,15 @@ function racketAI(rz, dt) {
// vx is kept so a body that is knocked down mid-chase can carry the run into its collapse.
earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt);
rz.vx = dx / d * step / dt;
} else { rz.vx = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
// vz beside vx (drawZombie's speed ghosts read both), and MOVEMENT READS: a genuine sprint
// kicks the surface up behind it — one puff per runDustEvery metres, the same per-court dust
// the ball's bounce already throws, so the effect can never wear the wrong colour
rz.vz = dz / d * step / dt;
if (TUNE.trailMin && step / dt > TUNE.trailMin
&& (rz.dustAcc = (rz.dustAcc || 0) + step) > TUNE.runDustEvery) {
rz.dustAcc = 0; dustPuff(rz.x, rz.z, 2, 0.6);
}
} else { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
// THE WOUNDS PLAY. Score-is-the-body was a picture until now: it lost an arm and swung exactly
// as far, lost its head and missed exactly as often. Both dials read the same racketStage the
// SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound.
@@ -1444,10 +1484,16 @@ function racketAI(rz, dt) {
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
// which across a game is an opponent that beats itself.
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub;
const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub)
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
// body had actually learned. The champion's near-zero flub is exactly what this opens.
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
// the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet
if (G.match) G.match.surprised = G.match.expected = false;
}
}
@@ -1792,6 +1838,7 @@ export function update(dt) {
// steer on the phase froze the player mid-burst the moment he was winning. The settled "no
// walking between points" rule is untouched — nothing arms a burst except the player.
if (G.phase === 'rally' || G.mgT > 0) {
const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
@@ -1860,11 +1907,21 @@ export function update(dt) {
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
if (mx) { P.facing = mx > 0 ? 1 : -1; P.flipT = 0; } // manual: instant, and it resets the earn clock
else earnFlip(P, 'facing', Math.abs(assist) > 0.25 ? (assist > 0 ? 1 : -1) : 0, dt);
// MOVEMENT READS: the player's own travel this frame, measured AFTER the clamps so a body
// pinned against the bound reads as standing (drawChar's speed ghosts read vx/vz). A real
// sprint — the auto-run chase, the horde steer — kicks the same per-court dust the bodies do.
P.vx = (P.x - px0) / dt; P.vz = (P.z - pz0) / dt;
const pSpd = Math.hypot(P.vx, P.vz);
if (TUNE.trailMin && pSpd > TUNE.trailMin
&& (P.dustAcc = (P.dustAcc || 0) + pSpd * dt) > TUNE.runDustEvery) {
P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6);
}
}
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
// handles swing/serve), and P.runT stops advancing outside the block, so a beat that began
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
// vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead.
else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } }
// The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
// in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
// off the edge of the screen either.
+23 -3
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe, LADDER } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
@@ -383,10 +383,24 @@ document.getElementById('brBack').addEventListener('click', () => {
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// ---- THE TRAINING PICKER (Oliver, 2026-08-28: "ein Trainingsbereich — Auswahl des Gegners, ein
// Spiel, tap to replay, bis man keine Lust mehr hat"). The whole ladder face-up, one tile per rung,
// wearing the same z_*head crop the draw and the walk-on wear. Tap = start — the wizard's own
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
let trainRung = 1;
const trainList = document.getElementById('trainList');
for (let n = 1; n <= LADDER; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
const boardFor = m => m === 'zombie' || m === 'cup' ? stats.MODE_MATCH
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating
@@ -404,7 +418,9 @@ function summary() {
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
// training banks nothing, so there is no mark to beat and the footer must not invent one
+ `<span>${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS'
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
@@ -440,6 +456,8 @@ function start(modeId = pickedMode) {
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); }
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
}
let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
@@ -523,6 +541,7 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
// carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
start();
});
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
@@ -713,6 +732,7 @@ addEventListener('keydown', e => {
// no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => {
if (G.mode !== 'splatter') return;
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
+26 -3
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH, matchFoe } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
@@ -805,9 +805,21 @@ function drawMachine(zb) {
}
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
function drawZombie(zb) {
function drawZombie(zb, ghost) {
if (zb.variant === 'o') return drawBarrel(zb);
if (zb.variant === 'm') return drawMachine(zb);
// MOVEMENT READS: a body genuinely sprinting (only racketAI writes vx/vz — the shamble never
// crosses trailMin) leaves two ghosts sampled back along its own velocity, the modal masthead's
// speed streak applied to a body. Ghosts draw first so the live sprite lands on top, and never
// during a named frame — a collapsing corpse must not smear on stale velocity.
if (!ghost && TUNE.trailMin && !zb.showFrame
&& Math.hypot(zb.vx || 0, zb.vz || 0) > TUNE.trailMin) {
for (const [k, a] of [[2, 0.10], [1, 0.22]]) {
ctx.save(); ctx.globalAlpha *= a;
drawZombie({ ...zb, x: zb.x - zb.vx * TUNE.trailK * k, z: zb.z - (zb.vz || 0) * TUNE.trailK * k }, true);
ctx.restore();
}
}
const rk = zb.variant === 'r';
// THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body
// drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing
@@ -1782,10 +1794,19 @@ export function drawLook(cv2, t, k, sk) {
const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex;
function drawChar(ch) {
function drawChar(ch, ghost) {
// No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
// of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
// as the man skating — and the idle pair's own weight shift is the whole of what he should be doing.
// MOVEMENT READS: the same two-ghost speed streak the bodies wear (see drawZombie) — gameplay's
// mover writes P.vx/vz, so only a genuine sprint (auto-run chase, horde steer) ever draws one.
if (!ghost && TUNE.trailMin && Math.hypot(ch.vx || 0, ch.vz || 0) > TUNE.trailMin) {
for (const [k, a] of [[2, 0.10], [1, 0.22]]) {
ctx.save(); ctx.globalAlpha *= a;
drawChar({ ...ch, x: ch.x - ch.vx * TUNE.trailK * k, z: ch.z - ch.vz * TUNE.trailK * k }, true);
ctx.restore();
}
}
const p = proj(ch.x, 0, ch.z);
// the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was
// ~34 logical px of faithful anatomy, which is mush. The near player anchors the curve (farB
@@ -2822,6 +2843,8 @@ export function render() {
// the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
ctx.fillText(G.match && G.match.cup
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED`
// training: the ladder is parked, so "opponent N · felled" would lie — name the rep instead
: G.match && G.match.train ? `TRAINING · ${matchFoe(G.match.opp).short}`
: G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
+87
View File
@@ -523,6 +523,9 @@ const passMopBeat = () => { // ...stopping at the walk-on: past it, the next o
// the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it
for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
// the walk-on is a MODAL now (2026-08-28) and it arrives WITH the opponent — tap it away too,
// or every step after this one runs against a frozen world
G.modal = null; G.modalQ.length = 0;
};
test('a match owns the court like every other mode', () => {
@@ -2028,5 +2031,89 @@ test('the stand reads the SIZE of the moment, and a small cheer inside a big one
crowd.cheer = crowd.pop = crowd.burst = 0;
});
// ---- 2026-08-28: the walk-on modal, the training court, and THE READ ---------------------------
test('the walk-on is a MODAL the player taps away, and it holds the world', () => {
// Oliver: "die Intros bitte als richtiges Modal, das man wegtappen muss — you can actually read
// it without time pressure, and tap it away fast when you know it." The timed 4 s card is gone.
G.seen.match = true; // the rules popup is not the subject here
assert.ok(_startMatch());
assert.ok(G.modal, 'the opponent announces as a modal, not a band card');
assert.equal(G.modal.title, cfg.matchFoe(1).name, 'it names the body');
assert.equal(G.modal.icon, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head');
const z0 = oppOf().z, intro0 = G.interlude.introT;
for (let i = 0; i < 60; i++) update(DT);
assert.equal(oppOf().z, z0, 'the frozen court holds while it is read');
assert.equal(G.interlude.introT, intro0, '...and the intro beat holds with it, however long that takes');
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE TRAINING COURT: the rung is parked, nothing banks, and bleeding out is a modal', () => {
assert.ok(_startMatch(false, null, 5), 'training rides the match machinery');
G.modal = null; G.modalQ.length = 0;
const d = G.match;
assert.ok(d.train, 'flagged as the training court');
assert.equal(d.opp, 5, 'the picked rung is the one that walks on');
const runGames0 = G.runGames;
G.interlude.introT = 0;
G.hp = 3; // a scuffed pool, to prove the reset
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
passMopBeat(); // the kill fall, the mop, the SAME body walking on
assert.equal(G.match.opp, 5, 'a felled body comes back as itself — the ladder never advances');
assert.equal(G.runGames, runGames0, 'no ladder metric moves on the training court');
assert.equal(G.hp, G.hpMax, 'every rep starts whole — the reset, without the trainer ceremony');
// ...and running out of blood is a popup and a fresh pool, never the death screen
G.interlude.introT = 0;
G.hp = 1;
resolveInterlude('O');
assert.notEqual(G.mode, 'eaten', 'the training court has no death in it');
assert.ok(G.modal && G.modal.title === 'IT BLED YOU OUT', 'bleeding out announces itself');
assert.equal(G.hp, G.hpMax, '...and hands the pool back');
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE READ: a habit is learned by repetition and a break wrong-foots the racket', () => {
// The twist for the post-COACH wall ("very fast, barely make mistakes"): repeat a lane and the
// opponent anticipates it, break an established habit and it is wrong-footed for that ball.
G.seen.match = true;
_startMatch(); G.modal = null; G.modalQ.length = 0;
const zb = oppOf(), d = G.match;
d.opp = cfg.LADDER; zb.foe = cfg.matchFoe(cfg.LADDER); // the champion's dials, live
G.interlude.introT = 0;
const hit = fx => { // one real player swing, aimed by a committed flick
G.phase = 'rally'; P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingCool = 999; // it must not return the rig's ball
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
G.swipe = { ax: Math.sign(fx), fx, fy: 0, mag: TUNE.flickFull };
keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
G.swipe = null;
assert.equal(ball.lastHit, 'P', 'the rig landed a swing');
ball.active = false; // the rally is not the subject
};
for (let i = 0; i < TUNE.readHabit; i++) hit(-1);
assert.equal(d.laneRun, TUNE.readHabit, 'a run of the same lane goes on the books');
assert.ok(!d.surprised && !d.expected, 'the habit itself surprises nobody');
hit(-1); // one more of the same: the anticipated ball
assert.ok(d.expected && !d.surprised, 'the repeated ball is anticipated — spam feeds the machine');
hit(1); // the break
assert.ok(d.surprised && !d.expected, 'the break wrong-foots him for this one flight');
assert.equal(d.laneRun, 1, '...and the new lane starts its own book');
G.modal = null; G.modalQ.length = 0;
endMatch();
// the dials must MATTER at the top and vanish at the bottom — pinned so they cannot drift:
const spd = n => (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (cfg.matchFoe(n).speed ?? 1);
assert.ok(spd(cfg.LADDER) * (1 - TUNE.surpSlow) < spd(14),
'a wrong-footed champion runs slower than the head coach at full stride');
const champFlub = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, cfg.LADDER - 1) * cfg.matchFoe(cfg.LADDER).flub;
assert.ok(champFlub < 0.02, '(the champion barely misses on his own — the wall being answered)');
assert.ok(champFlub + TUNE.surpFlub > 0.25, 'the surprise opens a real whiff window in that racket');
assert.ok(TUNE.readAnticipate <= 1, 'anticipation never reads better than perfect');
assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report