From 7372bfc5e88f40a40974a37ac6e1959c28b1fedd Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 18:14:18 -0400 Subject: [PATCH] =?UTF-8?q?Rev=2022:=20the=20self-stack=20resolves=20?= =?UTF-8?q?=E2=80=94=20one=20wizard=20cannot=20bounce=20to=20himself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A democratic monster touching its own creator opens a stack where one player wears both hats, and doPass matched the attacker first: every pass "bounced the exchange to the defender" — the same wizard — forever. Room 928D livelocked there at seq 83; once the boot kick revived its pump, the automaton politely passed 389 times. At rev 22 a self-stack pass RESOLVES (the claw lands, the game moves on); older ledgers hold their recorded bounces and replay so. 928D itself was repaired by truncating the no-op run and lifting it to rev 22, verified clean and unstuck on a copy before installation. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 8 +++++- packages/engine/test/creatures.test.ts | 37 ++++++++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/local.svelte.ts | 2 +- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 16910df..3062cf4 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -5071,7 +5071,13 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult { const state = clone(prev); const stack = state.stack!; const events: GameEvent[] = []; - if (playerId === stack.attackerId) { + // A self-stack (a democratic monster touching its own creator) has one + // party wearing both hats: their pass RESOLVES — bouncing it "to the + // defender" would hand the exchange back to themselves forever + // (rules rev 22; older ledgers hold those no-op bounces and replay so). + const selfStack = + stack.attackerId === stack.defenderId && (state.config.deckRev ?? 1) >= 22; + if (playerId === stack.attackerId && !selfStack) { // The attacker declining to answer bounces the exchange back so the // defender may stack further counters — but not past a total stop: // re-prompting then reads as "your shield failed" and goads the table diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 3022954..9057945 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -683,3 +683,40 @@ describe("collapsing walls crush monsters too (rules rev 21)", () => { expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0); }); }); + +describe("the self-stack resolves on a pass (rules rev 22)", () => { + function selfTouch(deckRev: number) { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + state = toRound2(state); + const creator = activePlayer(state); + state.creatures.push({ + id: "dm1", kind: "democratic-monster", controllerId: creator.id, + position: { x: creator.position.x - 1, y: creator.position.y }, + damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0, + attackUsed: false, justCreated: false, + wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [], + }); + const r = applyCommand(state, creator.id, { type: "moveCreature", creatureId: "dm1", direction: "E" }); + if (!r.ok) throw new Error(r.error); + return { state: r.state, creator: creator.id }; + } + + it("rev 22: passing your own monster's touch takes the claw and moves on", () => { + const { state, creator } = selfTouch(22); + if (!state.stack) return; // a wall between: the touch never opened + expect(state.stack.attackerId).toBe(creator); + expect(state.stack.defenderId).toBe(creator); + const r = applyCommand(state, creator, { type: "pass" }); + if (!r.ok) throw new Error(r.error); + expect(r.state.stack).toBeNull(); + expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13); + }); + + it("rev 21 keeps its recorded bounce, no-op as it was", () => { + const { state, creator } = selfTouch(21); + if (!state.stack) return; + const r = applyCommand(state, creator, { type: "pass" }); + if (!r.ok) throw new Error(r.error); + expect(r.state.stack).not.toBeNull(); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 1f88fdb..104dcc7 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 21; +const RULES_REV = 22; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 3faf6dd..5efc996 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 21, + deckRev: 22, }; const { state, events } = createGame(config); for (const e of events) {