diff --git a/HANDOFF.md b/HANDOFF.md index 53b6cc3..d3376e1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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. diff --git a/gameplay.js b/gameplay.js index 781a0d6..4dd1517 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 diff --git a/index.html b/index.html index 309d80c..037da56 100644 --- a/index.html +++ b/index.html @@ -398,6 +398,6 @@ - + diff --git a/test.js b/test.js new file mode 100644 index 0000000..d1e4cf9 --- /dev/null +++ b/test.js @@ -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