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:
1 parent
4569981365
commit
05930f0877
4 files changed
+210
-5
No files matched your search
+25
-1
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
Reference in new issue
Block a user