One mode owns the court: the duel can no longer start inside a horde, and the game has tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-11 12:55:37 +02:00
1 parent 4569981365
commit 05930f0877
4 files changed
+210 -5

No files matched your search

+25 -1
View File
@@ -5,7 +5,7 @@ everything they specified is shipped, and what still matters from them is record
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log. detail of any individual change, read the git log.
Last updated 2026-08-08 (testgroup round: popup policy, ghost hand, barrel economy). **Start with "How to work on this", "OPEN — leftovers" and "LATER — Last updated 2026-08-11 (the third freeze: court-ownership guards + `node test.js`). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
@@ -376,6 +376,30 @@ 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 `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. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: the third freeze — court ownership enforced, and the first tests (2026-08-11)
Oliver hit the horde freeze a THIRD time, earlier in a run: **a sudden-death duel started inside a
live horde.** Welded on the right side, no drag (duel's `pretoss` kills steering), no serve
(`G.horde` blocks taps), spawns kept coming. The full chain: kills still score during the SET card
beat (spawn director keeps running there), a game won mid-card re-armed `G.clearing`/`G.afterClear`
while `pendingHorde` was already latched → horde started with a clear parked → mid-horde the gun
mowed the court to an empty frame between spawns → `G.clearing && !blockers` advanced the beat
**mid-horde** → `duelDue` fired `startDuel()`. The watchdog was blind to it by design: its signature
includes `horde.t`, which ticks every frame, so the state always looked alive.
Fixed at the chokepoints, not the call sites:
- `update()`'s hand-clear advance now requires `!G.horde` — a latched clear waits for `endHorde`.
- `startDuel()` and `startHorde()` hard-refuse while any other mode owns the court
(interlude/horde/bossFight). All present and future callers inherit this.
- `splatPoint()` is a no-op during the duel — `resolveInterlude` is the duel's only exit. (A machine
or leftover killed mid-duel used to bank score and could re-enter `proceedAfterClear` mid-duel.)
- **Invariant net** in `watchdog()`: if horde+interlude ever coexist anyway, or the phase leaves
`rally` during a horde, it repairs on the spot and logs `INVARIANT` into `G.missLog`.
**The game now has tests: `node test.js`** — headless Node, real game modules, stubbed platform
(DOM/WebAudio/storage). Seven cases pin the ownership contract, including a scripted repro of the
field freeze and the injected-corruption repair. Run them before touching `gameplay.js` beats.
## Session log: the testgroup round — popups, ghost hand, barrel economy (2026-08-08) ## Session log: the testgroup round — popups, ghost hand, barrel economy (2026-08-08)
First round of feedback from the small test group (via Oliver), seven items, all landed. First round of feedback from the small test group (via Oliver), seven items, all landed.
+25 -3
View File
@@ -470,6 +470,10 @@ function pointTo(side, reason) {
const blocksClear = z => !'rmo'.includes(z.variant); const blocksClear = z => !'rmo'.includes(z.variant);
export function splatPoint(side, quiet = false) { export function splatPoint(side, quiet = false) {
// The duel is one clean tennis point and resolveInterlude is its ONLY exit. A machine or leftover
// killed mid-duel used to score here, and a game banked mid-duel re-entered proceedAfterClear —
// which can start a boss, a card, or a horde while the duel still owns the court.
if (G.interlude) return;
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
if (side === 'P') G.killCount = 0; if (side === 'P') G.killCount = 0;
const gameOver = advancePoint(side); const gameOver = advancePoint(side);
@@ -708,7 +712,9 @@ function tickTestMode(dt) {
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. // Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed. // This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
export function startDuel() { export function startDuel() {
if (G.interlude) return; // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins G.weapon = 0; // tennis rules for the duel: plain balls, no tins
@@ -786,6 +792,19 @@ export function endBossFight() {
let stuckT = 0, stuckSig = ''; let stuckT = 0, stuckSig = '';
function watchdog(dt) { function watchdog(dt) {
if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate
// INVARIANT NET, before the clock-based test — because the clock-based test is blind to exactly
// this: a horde's own timer keeps ticking, so the sig always "moves" while the player is welded
// in place. Two states the guards above should make impossible; if a path still reaches them,
// repair on the spot and leave evidence in the miss log.
if (G.horde && G.interlude) { // a duel inside a horde: no steering, no serving, spawns keep coming
logSwing({ result: 'INVARIANT', reason: 'duel active during horde — interlude dropped, duel re-scheduled' });
G.interlude = false; G.duelDue = true; G.duelLerp = 0;
}
if (G.horde && G.phase !== 'rally') { // pretoss under a horde = the finger steers nothing
logSwing({ result: 'INVARIANT', reason: `phase '${G.phase}' during horde — forced back to rally`, phase: G.phase });
G.phase = 'rally'; ball.active = false;
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
}
const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over' const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over'
|| (G.interlude && G.interlude.introT > 0) || (G.interlude && G.interlude.introT > 0)
|| (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there || (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there
@@ -867,7 +886,7 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2);
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
function startHorde() { function startHorde() {
if (G.horde) return; if (G.horde || G.interlude || G.bossFight) return; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
@@ -1499,7 +1518,10 @@ export function update(dt) {
if (G.meleeT > 0) G.meleeT -= dt; if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
watchdog(dt); watchdog(dt);
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance // !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched
// before the wave began (a game won during the set card) then advanced the beat mid-horde —
// which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde.
if (G.clearing && !G.horde && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt); if (G.mode === 'transition') transition(dt);
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about // --- interlude trigger: the court is clear, an opponent holds the far court, you're about
+1 -1
View File
@@ -398,6 +398,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=146"></script> <script type="module" src="main.js?v=147"></script>
</body> </body>
</html> </html>
+159
View File
@@ -0,0 +1,159 @@
// Headless state-machine tests: `node test.js`.
// The game's modules are imported for real; only the platform (DOM, WebAudio, storage) is stubbed.
// These pin the court-ownership contract: horde, sudden-death duel, boss and changeover are
// EXCLUSIVE control modes — no interleaving of beats may ever let two of them run at once.
// Born from three field freezes: the last one was a duel starting inside a live horde
// (pretoss = no steering, horde = no serving, spawns keep coming → unrecoverable).
import assert from 'node:assert/strict';
// ---- platform stubs, installed before the game modules load ----------------
const absorb = new Proxy(function () {}, {
get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb),
set: () => true,
apply: () => absorb,
construct: () => absorb,
});
const fakeCtx = () => new Proxy({}, {
get: (t, p) => {
if (p === 'canvas') return fakeCanvas();
if (p === 'measureText') return () => ({ width: 0 });
if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h });
if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} });
return () => undefined;
},
set: () => true,
});
function fakeCanvas() {
return {
width: 270, height: 480, style: {},
getContext: () => fakeCtx(),
addEventListener() {}, removeEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }),
toDataURL: () => '',
};
}
const store = new Map();
globalThis.localStorage = {
getItem: k => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
};
globalThis.document = {
getElementById: () => fakeCanvas(),
createElement: () => fakeCanvas(),
addEventListener() {}, removeEventListener() {},
body: { style: {}, appendChild() {} },
};
globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional)
globalThis.AudioContext = function () { return absorb; };
globalThis.webkitAudioContext = globalThis.AudioContext;
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
globalThis.requestAnimationFrame = () => 0;
globalThis.devicePixelRatio = 2;
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, score, ball, setupPoint } = await import('./state.js');
const { update, startDuel, forceZombies, splatPoint, restartRun, resolveInterlude } = await import('./gameplay.js');
const { zombies, spawnZombie } = await import('./zombies.js');
const { TUNE } = await import('./config.js');
const DT = 1 / 60;
function freshSplatter() {
restartRun();
G.mode = 'splatter';
G.everTransitioned = true;
setupPoint();
}
let passed = 0;
function test(name, fn) {
freshSplatter();
fn();
passed++;
console.log(' ok - ' + name);
}
// ---- the contract -----------------------------------------------------------
test('sanity: a horde starts and owns the court in rally phase', () => {
forceZombies();
assert.ok(G.horde, 'horde should start on a clean court');
assert.equal(G.phase, 'rally', 'horde steering requires rally phase');
});
test('a duel refuses to start while a horde is live', () => {
forceZombies();
startDuel();
assert.equal(G.interlude, false, 'startDuel must refuse during a horde');
});
test('a horde refuses to start while a duel is live', () => {
startDuel();
assert.ok(G.interlude, 'duel should start on a clean court');
forceZombies();
assert.equal(G.horde, null, 'startHorde must refuse during a duel');
});
test('field repro: a clear latched before the horde must not advance the beat mid-horde', () => {
// A game won during the set card left clearing+afterClear armed while pendingHorde cashed:
spawnZombie(0, 5, 'a');
G.clearing = true; G.afterClear = 'game'; G.duelDue = true;
forceZombies();
assert.ok(G.horde, 'setup: horde live over a latched clear');
zombies.length = 0; // the gun mows the court down to an empty frame between spawns
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; }
assert.equal(G.interlude, false, 'the scheduled duel must NOT fire inside the horde');
assert.ok(G.horde, 'the horde must keep running');
assert.ok(G.clearing, 'the clear waits for the horde to end');
// ...and once the horde expires, the parked clear advances and the duel fires — sequentially.
for (let i = 0; i < (TUNE.hordeLen + 3) * 10 && G.horde; i++) { update(0.1); zombies.length = 0; }
assert.equal(G.horde, null, 'horde over');
for (let i = 0; i < 30 && !G.interlude; i++) { update(DT); if (!G.interlude) zombies.length = 0; }
assert.ok(G.interlude, 'the parked duel fires at the first clear AFTER the horde');
});
test('kills mid-duel do not score: resolveInterlude is the only exit', () => {
startDuel();
const before = JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]);
for (let i = 0; i < 20; i++) splatPoint('P');
splatPoint('O');
assert.equal(JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]), before,
'splatPoint must be a no-op during the duel');
resolveInterlude('P');
assert.equal(G.interlude, false, 'the duel still resolves normally');
});
test('invariant net: an injected duel-inside-horde is repaired within one frame', () => {
forceZombies();
G.interlude = { introT: 0 }; // simulate an unknown fourth path corrupting the state
G.phase = 'pretoss'; // the welded-player symptom: no steering, no serving
update(DT);
assert.equal(G.interlude, false, 'watchdog must drop the impossible duel');
assert.equal(G.duelDue, true, 'and re-schedule it instead of eating it');
assert.equal(G.phase, 'rally', 'and hand the finger back to the player');
assert.ok(G.missLog.some(e => e.result === 'INVARIANT'), 'the repair leaves evidence in the miss log');
});
test('a pending horde defers while a duel runs, then fires after it', () => {
startDuel();
G.interlude.introT = 0;
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold while the duel owns the court');
assert.ok(G.pendingHorde, 'and stay armed');
resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // dismiss the win popups; the over beat then expires
for (let i = 0; i < 300 && !G.horde; i++) update(DT); // the post-duel over beat runs 2.2 s
assert.ok(G.horde, 'the set still gets its horde, after the duel');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report