From 3b0aa018677be903b223ebb9019937718b8bd516 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 16:51:44 -0400 Subject: [PATCH] Rev 18: the democratic monster's claw outlives the first wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its one-attack-per-round refreshed inside beginTurnFor when the turn index matched the roll-off winner's seat — and only the living begin turns, so once that wizard fell the claw never refreshed again: one attack, then a lifetime of harmless bumping. The refresh now lives at the true round boundary in the turn-advance loop, which passes dead seats without skipping the count. Earlier ledgers keep their gummed monster and replay so. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 17 +++++++++++++--- packages/engine/test/creatures.test.ts | 27 ++++++++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/local.svelte.ts | 2 +- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 1992cb8..4f47893 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -5505,8 +5505,9 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi c.scorchedThisTurn = []; if (c.kind === "democratic-monster") { c.movementUsed = 0; - // its single attack refreshes per ROUND: on the first player's turn - if (index === state.turn.firstIndex) c.attackUsed = false; + // Rev <18 refreshed the claw here — which never fires once the + // first-rolled wizard is dead, since only the living begin turns. + if ((state.config.deckRev ?? 1) < 18 && index === state.turn.firstIndex) c.attackUsed = false; } else if (c.controllerId === player.id) { c.movementUsed = 0; c.wallPassUsed = 0; @@ -5709,7 +5710,17 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { let next = state.turn.activeIndex; for (;;) { next = (next + 1) % n; - if (next === state.turn.firstIndex) state.turn.round++; + if (next === state.turn.firstIndex) { + state.turn.round++; + // The democratic monster's one claw per round refreshes at the TRUE + // round boundary — even when the first-rolled wizard is dead or + // skipped and never begins a turn (rules rev 18). + if ((state.config.deckRev ?? 1) >= 18) { + for (const c of state.creatures) { + if (c.kind === "democratic-monster") c.attackUsed = false; + } + } + } const candidate = state.players[next]!; if (!candidate.alive) continue; if (candidate.lostTurns > 0) { diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index a5104a6..8c85046 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -589,3 +589,30 @@ describe("the wave carries monsters (rules rev 17)", () => { throw new Error("setup: no horizontal wall with a floor below"); }); }); + +describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => { + it("refreshes each round even with the roll-off winner dead", () => { + let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 }); + state = toRound2(state); + const firstId = state.players[state.turn.firstIndex]!.id; + const dm = { + id: "dm1", kind: "democratic-monster" as const, controllerId: state.players[0]!.id, + position: { x: 0, y: 0 }, damage: 0, maxDamage: 5, movesPerTurn: 3, + movementUsed: 0, attackUsed: true, justCreated: false, + wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [] as string[], + }; + state.creatures.push(dm); + // The roll-off winner falls. + const first = state.players.find((p) => p.id === firstId)!; + first.alive = false; + first.finalHand = [...first.hand]; + // Walk a full round of the survivors: the claw must refresh. + for (let i = 0; i < 4 && state.creatures[0]!.attackUsed; i++) { + const active = activePlayer(state); + const r = applyCommand(state, active.id, { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + expect(state.creatures[0]!.attackUsed).toBe(false); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 0d51302..1dfb2dc 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 = 17; +const RULES_REV = 18; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index b76b5f6..3fbef05 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: 17, + deckRev: 18, }; const { state, events } = createGame(config); for (const e of events) {