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
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
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
@@ -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
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)
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);
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 = 0;
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.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
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
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
@@ -786,6 +792,19 @@ export function endBossFight() {
let stuckT = 0, stuckSig = '';
function watchdog(dt) {
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'
|| (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
@@ -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
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
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
@@ -1499,7 +1518,10 @@ export function update(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
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);
// --- 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>
<script type="module" src="main.js?v=146"></script>
<script type="module" src="main.js?v=147"></script>
</body>
</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