The MASTER KEY earns its display

"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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-25 20:46:23 -04:00
co-authored by Claude Fable 5
parent 805fb09782
commit 64e4e243dc
3 changed files with 81 additions and 3 deletions
+19 -2
View File
@@ -1233,8 +1233,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
keepInHand: true, keepInHand: true,
// "Unlocks any door (door relocks behind you). Do not discard when used. // "Unlocks any door (door relocks behind you). Do not discard when used.
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK." // 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) => 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": { "remove-lock": {
kind: "neutral", kind: "neutral",
@@ -3941,7 +3947,15 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
p.position = { ...warp.to.cell }; p.position = { ...warp.to.cell };
via = "warp"; via = "warp";
crossedFirewall = true; 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; p.position = dest;
via = "step"; via = "step";
} else if (edge === "firewall") { } else if (edge === "firewall") {
@@ -4737,6 +4751,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) { if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`); 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 // 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. // charge per use, one use per turn; discarded when the last charge goes.
@@ -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"); 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);
});
});
+5 -1
View File
@@ -338,7 +338,7 @@
"wall-of-fire": "click a corridor line for the fire", "wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once", "waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door", "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", "jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good", "remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door", "create-door": "click a wall for the new door",
@@ -2205,6 +2205,10 @@
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label> <label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/if} {/if}
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
{/if}
{#if selectedCard?.cardId === "rotate-sector"} {#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label> <label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span> click the sector</span> <span> click the sector</span>