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
+39 -1
View File
@@ -205,6 +205,8 @@ export interface CastParams {
clockwise?: boolean;
/** MEGA-MONSTER: which stat doubles. */
boost?: "life" | "movement";
/** PICK LOCK / MASTER KEY: hold the door open for others. */
hold?: boolean;
}
export interface GameConfig {
@@ -250,6 +252,10 @@ export interface GameState {
doorStates: Record<string, "jammed" | "removed">;
/** Door edges unlocked until the end of the current turn. */
openDoorEdges: string[];
/** Doors physically HELD open ("You may 'hold the door open' for others"
* — MASTER KEY, PICK LOCK): unlocked past end of turn, until the holder
* steps out of adjacency or falls. */
heldDoors: { key: string; by: PlayerId; cell: Cell; side: Side }[];
/** Walls/firewalls conjured during play (dispellable), by edge key. */
createdEdges: Record<string, true>;
/** Square-filling creations, by cell key. */
@@ -573,6 +579,8 @@ export type GameEvent =
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "wallDamaged"; player: PlayerId; edge: { cell: Cell; side: Side }; amount: number; total: number; needed: number; source: string }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorHeld"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "doorReleased"; edge: { cell: Cell; side: Side } }
| { type: "doorsRelocked"; count: number }
| { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "lockRemoved"; player: PlayerId; edge: { cell: Cell; side: Side } }
@@ -2798,6 +2806,10 @@ function remapState(
state.doorStates = remapRecord(state.doorStates, mapEdgeKey);
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
for (const h of state.heldDoors) {
h.key = mapEdgeKey(h.key);
if (inSector(h.cell)) h.cell = mapCell(h.cell);
}
state.squareContents = remapRecord(state.squareContents, mapCellKey);
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
@@ -2967,9 +2979,27 @@ function unlockDoor(
}
if (!state.openDoorEdges.includes(key)) state.openDoorEdges.push(key);
events.push({ type: "doorUnlocked", player: caster.id, edge: found, withCardId: cardId });
if (cmd.params?.hold) {
if (!state.heldDoors.some((h) => h.key === key)) {
state.heldDoors.push({ key, by: caster.id, cell: found.cell, side: found.side });
}
events.push({ type: "doorHeld", player: caster.id, edge: found });
}
return null;
}
/** 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. */
function sweepHeldDoors(state: GameState, events: GameEvent[]): void {
for (const h of [...state.heldDoors]) {
const holder = state.players.find((p) => p.id === h.by);
if (holder?.alive && isAdjacentToEdge(holder.position, h.cell, h.side)) continue;
state.heldDoors = state.heldDoors.filter((x) => x !== h);
events.push({ type: "doorReleased", edge: { cell: h.cell, side: h.side } });
}
}
function attachSustained(
state: GameState,
events: GameEvent[],
@@ -3131,6 +3161,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
chaosPending: null,
doorStates: {},
openDoorEdges: [],
heldDoors: [],
createdEdges: {},
squareContents: {},
groundObjects: {},
@@ -3191,6 +3222,12 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
// Command application
export function applyCommand(state: GameState, playerId: PlayerId, command: Command): CommandResult {
const result = applyCommandInner(state, playerId, command);
if (result.ok) sweepHeldDoors(result.state, result.events);
return result;
}
function applyCommandInner(state: GameState, playerId: PlayerId, command: Command): CommandResult {
if (state.phase !== "playing") return err("game is over");
if (state.pendingDiscard) {
@@ -3439,7 +3476,8 @@ 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))) {
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key) ||
state.heldDoors.some((h) => h.key === key))) {
p.position = dest;
via = "step";
} else if (edge === "firewall") {
+3
View File
@@ -62,6 +62,8 @@ export interface GameView {
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
openDoorEdges: string[];
/** Door edges held open by a standing wizard. */
heldDoorEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[];
creatures: CreatureState[];
@@ -137,6 +139,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges],
heldDoorEdges: state.heldDoors.map((h) => h.key),
knownIllusionEdges,
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
wandCharges: { ...state.wandCharges },
@@ -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);
});
});
+8
View File
@@ -128,6 +128,8 @@
let tradeFrom = $state<{ x: number; y: number } | null>(null);
/** rotate-sector direction. */
let rotateCW = $state(true);
/** pick-lock / master-key: prop the door for others once it opens. */
let holdDoor = $state(false);
/** mega-monster: which stat the chosen monster doubles. */
let megaBoost = $state<"life" | "movement">("life");
/** teleport: the marked destination awaiting its confirming second tap. */
@@ -300,6 +302,7 @@
selectedCreature = null;
selectedCard = null;
megaBoost = "life";
holdDoor = false;
pendingTeleport = null;
attachedNumber = null;
attachedMods = [];
@@ -713,6 +716,8 @@
type: "cast",
instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side },
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
? { params: { hold: true } } : {}),
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
@@ -1550,6 +1555,9 @@
onclick={() => (megaBoost = "movement")}>movement</button>
<span> then tap the monster</span>
{/if}
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/if}
{#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span> click the sector</span>
+6 -1
View File
@@ -130,7 +130,9 @@
// A door's lock can be gone for good, jammed shut, or picked open
// for the turn — each earns its own look.
const lock = state === "door"
? (view.doorStates[key] ?? (view.openDoorEdges.includes(key) ? "ajar" : null))
? (view.doorStates[key] ??
(view.heldDoorEdges.includes(key) ? "held"
: view.openDoorEdges.includes(key) ? "ajar" : null))
: null;
return { kind, x, y, state, lock };
}),
@@ -363,6 +365,7 @@
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
: e.lock === "jammed" ? "lock jammed — sealed for good"
: e.lock === "held" ? "held open by a standing wizard"
: e.lock === "ajar" ? "unlocked until end of turn" : null}
{#if e.kind === "V"}
<rect
@@ -635,6 +638,8 @@
.door.jammed { fill: #3a2a18; stroke: #14100b; }
/* Picked or keyed open until end of turn. */
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
/* Held open: a wizard stands propping it. */
.door.held { fill: #d4bd8e; stroke: #2e7d32; }
.firewall {
fill: #d0342c;
stroke: #7c1a14;
+2
View File
@@ -74,6 +74,8 @@ export function humanize(e: GameEvent): string | null {
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
case "doorUnlocked": return `${e.player} unlocks a door.`;
case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`;