diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index f772a6d..fcef469 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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; /** 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; /** 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") { diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 666924e..54edb59 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -62,6 +62,8 @@ export interface GameView { /** Accumulated attack damage per edge (public — cracks show). */ wallDamage: Record; 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 }, diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index bbab4ae..b88b89c 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -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); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 407b099..4936105 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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 — then tap the monster {/if} + {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} + + {/if} {#if selectedCard?.cardId === "rotate-sector"} — click the sector diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 3c1d714..45a0e26 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -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"}