The door can be held open, as both key cards always promised

MASTER KEY and PICK LOCK each read: "You may 'hold the door open' for
others, if you wish" — and the engine always slammed it at end of
turn. Now the cast takes a hold param: the door stays unlocked past
the turn, for anyone, as long as its holder stands adjacent and
alive. A step away, a shove, a teleport, or a killing blow lets it
swing shut — swept after every command, since anything can move a
wizard. New state, new param: no old ledger contains either, so no
rev gate is needed.

The client offers a "hold the door open" checkbox when either card is
selected; a held door shows pale with a green jamb ("held open by a
standing wizard"), and the chronicle records the holding and the
shutting. Pinned: a held door outlives the turn and admits the other
wizard; walking away releases it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 13:15:57 -04:00
co-authored by Claude Fable 5
parent d2b40ec6f8
commit a0c65413ef
6 changed files with 122 additions and 3 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, sustainedOn, type GameState } from "../src/game";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -346,3 +346,66 @@ describe("cast modifiers", () => {
expect(d.lostTurns).toBe(1); // the stun still applies
});
});
describe("holding the door open (Pick Lock / Master Key)", () => {
function doorRig() {
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 });
state = toRound2(state);
// Find a door edge; stand the acting player beside it.
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 holder = activePlayer(state);
holder.position = { ...cell };
return { state, key, cell, side, holder: holder.id };
}
throw new Error("setup: seed 42 grew a maze with no doors");
}
it("a held door outlives the turn and admits another wizard", () => {
let { state, key, cell, side, holder } = doorRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
state = must(state, holder, { type: "endTurn", draw: 0 });
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
const guest = state.players.find((p) => p.id !== holder)!;
guest.position = { ...cell };
const r = applyCommand(state, guest.id, { type: "move", direction: side });
if (!r.ok) throw new Error(r.error);
const through = r.state.players.find((p) => p.id === guest.id)!;
expect(cellKey(through.position)).toBe(cellKey(neighbor(cell, side)));
});
it("the door swings shut the moment the holder steps away", () => {
let { state, key, cell, side, holder } = doorRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
// March the holder until adjacency breaks; the sweep must release.
let walked = state;
let released = false;
const dirs: Side[] = ["N", "E", "S", "W"];
for (const d1 of dirs) {
const r1 = applyCommand(walked, holder, { type: "move", direction: d1 });
if (!r1.ok) continue;
if (!r1.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
for (const d2 of dirs) {
const r2 = applyCommand(r1.state, holder, { type: "move", direction: d2 });
if (!r2.ok) continue;
if (!r2.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
}
if (released) break;
}
expect(released).toBe(true);
});
});