From e7c8ddc0b3c90c00e09df8161dc0131271facb78 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 13:50:38 -0400 Subject: [PATCH] Creatures walk open doorways, and the interruption gets its fanfare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player movement asked the full question of a door — removed, keyed this turn, or held — while creature movement only saw "door" and refused, so a skeleton balked at a doorway its master had unlocked for good. One doorIsOpen() now answers for both, and an open doorway costs a wraith none of its wall passes. And the interruption window earns the modal it deserved: "The maze holds its breath" — the card shown, the instruction plain, dismissed with "Seize it". No more hunting the rail for a slip while time stands parted. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 21 +++++++++++++++---- packages/engine/test/creatures.test.ts | 29 ++++++++++++++++++++++++++ packages/web/src/App.svelte | 19 +++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 3d89e4a..1463336 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2578,9 +2578,15 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C const from = creature.position; const target = stepTarget(view, creature.position, direction); if (target.kind === "blocked") { - // WRAITH: "can move ... through 1 wall or object per turn." const dest = neighbor(creature.position, direction); - if ( + // An open doorway (lock removed, keyed, or held) is no obstacle — + // and costs a wraith nothing. + if (target.by === "door" && doorIsOpen(state, edgeKey(creature.position, direction)) && + view.cells[cellKey(dest)] && + state.squareContents[cellKey(dest)]?.kind !== "stone") { + creature.position = dest; + } else if ( + // WRAITH: "can move ... through 1 wall or object per turn." creature.wallPassesPerTurn > creature.wallPassUsed && view.cells[cellKey(dest)] && state.squareContents[cellKey(dest)]?.kind !== "stone" @@ -2998,6 +3004,14 @@ function unlockDoor( return null; } +/** An open doorway: lock removed for good, keyed or picked this turn, or + * held by a standing wizard. */ +function doorIsOpen(state: GameState, key: string): boolean { + return state.doorStates[key] === "removed" || + state.openDoorEdges.includes(key) || + state.heldDoors.some((h) => h.key === key); +} + /** A held door needs its holder: standing adjacent and alive. Anything that * moves or fells them — a step, a shove, a teleport, a killing blow — lets * the door swing shut. */ @@ -3486,8 +3500,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { if (isBlinded(state, p)) return blindBump(state, events, p, direction); return err("blocked"); } - if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key) || - state.heldDoors.some((h) => h.key === key))) { + if (edge === "door" && doorIsOpen(state, key)) { p.position = dest; via = "step"; } else if (edge === "firewall") { diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 172bf46..ab664b8 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -521,3 +521,32 @@ describe("elimination sweeps the board either way (rules rev 14)", () => { expect(state.sustained.some((s) => s.targetId === "c")).toBe(false); }); }); + +describe("creatures walk open doorways", () => { + it("a skeleton passes a door whose lock was removed", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 }); + state = toRound2(state); + const view = boardView(state); + for (const [key, edge] of Object.entries(view.edges)) { + if (edge !== "door") continue; + const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + const side = kind === "V" ? ("E" as Side) : ("S" as Side); + state.doorStates[key] = "removed"; + state.creatures.push({ + id: "sk1", kind: "skeleton", controllerId: activePlayer(state).id, + position: { x, y }, damage: 0, maxDamage: 4, movesPerTurn: 3, + movementUsed: 0, attackUsed: false, justCreated: false, + wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [], + }); + const r = applyCommand(state, activePlayer(state).id, { + type: "moveCreature", creatureId: "sk1", direction: side, + }); + if (!r.ok) throw new Error(r.error); + const sk = r.state.creatures.find((c) => c.id === "sk1")!; + expect(cellKey(sk.position)).toBe(cellKey({ x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) })); + return; + } + throw new Error("setup: seed 42 grew a maze with no doors"); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 8d1d2e1..7c18943 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -39,6 +39,11 @@ /** Which pending attack the player has already acknowledged (modal dismissed). */ const fxWorkshop = new URLSearchParams(location.search).has("fx"); let attackNoticeSeen = $state(null); + /** The interruption fanfare, dismissed once per window. */ + let momentSeen = $state(false); + $effect(() => { + if (!view?.outOfTurnWindow) momentSeen = false; + }); /** Live spell flourishes on the board (cosmetic, self-expiring). */ let boardFx = $state([]); let fxCancels: (() => void)[] = []; @@ -1008,6 +1013,20 @@ {/if} + {#if view && view.outOfTurnWindow?.playerId === view.you && !momentSeen} +
+
+
The maze holds its breath
+
+
+ Time parts for your {cardDef(view.outOfTurnWindow.kind).name} — + pick an attack card and a target, or let the moment pass. +
+ +
+
+ {/if} + {#if openingRolls}