From 64e4e243dc6d61004de9d2925a5c462e663b77b8 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:46:23 -0400 Subject: [PATCH] The MASTER KEY earns its display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Display Immediately," says the card — now the key lives up to it. Cast bare (a new Display button beside the hold-the-door checkbox), it goes straight on display; once displayed, it turns in every lock its bearer walks through — no more casting at each door — with the door relocking behind them at turn's end like any picked lock, and the doorUnlocked event still swinging the door in the reels. Cast at an adjacent door it still works one lock like PICK LOCK, including holding the door open for others. A JAMmed LOCK refuses it as ever, and the adjacent peek through workable locks already rode the rev-2 sight rules. Replay-safe ungated: bare casts were never recorded (they were refused), displayed cards still count against the hand limit, and walking through a locked door only widens what is accepted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 21 ++++++- .../engine/test/durations-doors-cards.test.ts | 57 +++++++++++++++++++ packages/web/src/App.svelte | 6 +- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b302ee7..67baafb 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -1233,8 +1233,14 @@ const CARD_EFFECTS: Record keepInHand: true, // "Unlocks any door (door relocks behind you). Do not discard when used. // Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK." + // Cast bare, it simply goes on display, and a DISPLAYED key turns in + // every lock its bearer walks through (see doMove) — no further casts. + // Cast at a door, it works that one lock like PICK LOCK and may hold + // the door open for others. resolve: (state, events, caster, cmd) => - unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }), + cmd.target + ? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }) + : null, }, "remove-lock": { kind: "neutral", @@ -3941,7 +3947,15 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { p.position = { ...warp.to.cell }; via = "warp"; crossedFirewall = true; - } else if (edge === "door" && doorIsOpen(state, key)) { + } else if (edge === "door" && + (doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) { + // The displayed MASTER KEY turns in every lock it meets: the walker + // passes without another cast, and the door relocks behind them at + // turn's end like any picked lock. A JAMmed LOCK still refuses it. + if (!doorIsOpen(state, key)) { + state.openDoorEdges.push(key); + events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" }); + } p.position = dest; via = "step"; } else if (edge === "firewall") { @@ -4737,6 +4751,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) { return err(`${def.name} is already displayed`); } + if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) { + return err("the key is already on display"); + } // Magic wands: charged on first use by the number card(s) played; one // charge per use, one use per turn; discarded when the last charge goes. diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index ad91402..641f032 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -506,3 +506,60 @@ describe("a held door is an open doorway to the eye", () => { if (!refused.ok) expect(refused.error).toContain("line of sight"); }); }); + +describe("the displayed MASTER KEY", () => { + function keyRig() { + let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] }); + 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(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + const cell = { x, y }; + const side = kind === "V" ? ("E" as Side) : ("S" as Side); + const keeper = activePlayer(state); + keeper.position = { ...cell }; + return { state, cell, side, key, keeper }; + } + throw new Error("setup: seed 42 grew a maze with no doors"); + } + + it("cast bare, the key goes on display — once", () => { + const { state, keeper } = keyRig(); + const mk = giveCard(state, keeper.id, "master-key", "MK"); + const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId }); + expect(cast.ok).toBe(true); + if (cast.ok) { + const after = cast.state.players.find((p) => p.id === keeper.id)!; + expect(after.displayed).toContain(mk.instanceId); + expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true); + const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId }); + expect(again.ok).toBe(false); + } + }); + + it("its bearer walks through locked doors, which relock behind them", () => { + const { state, cell, side, key, keeper } = keyRig(); + const mk = giveCard(state, keeper.id, "master-key", "MK"); + keeper.displayed.push(mk.instanceId); + const r = applyCommand(state, keeper.id, { type: "move", direction: side }); + expect(r.ok).toBe(true); + if (r.ok) { + const after = r.state.players.find((p) => p.id === keeper.id)!; + expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side))); + // Unlocked for the turn — the relock rides the usual end-of-turn sweep. + expect(r.state.openDoorEdges).toContain(key); + expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true); + } + }); + + it("a JAMmed LOCK refuses even the master key", () => { + const { state, side, key, keeper } = keyRig(); + const mk = giveCard(state, keeper.id, "master-key", "MK"); + keeper.displayed.push(mk.instanceId); + state.doorStates[key] = "jammed"; + const r = applyCommand(state, keeper.id, { type: "move", direction: side }); + expect(r.ok).toBe(false); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 22ab213..5e7eb15 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -338,7 +338,7 @@ "wall-of-fire": "click a corridor line for the fire", "waterwall": "click a corridor line — the wave collapses at once", "pick-lock": "click a locked door", - "master-key": "click a locked door", + "master-key": "click a locked door — or Display it once and walk through every lock", "jam-lock": "click a door to jam its lock solid", "remove-lock": "click a door to strip its lock for good", "create-door": "click a wall for the new door", @@ -2205,6 +2205,10 @@ {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} {/if} + {#if selectedCard?.cardId === "master-key" && isYourTurn && + !me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)} + + {/if} {#if selectedCard?.cardId === "rotate-sector"} — click the sector