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