diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index fcef469..507c7eb 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -326,9 +326,19 @@ export function losBlockers(state: GameState): Record { return blockers; } +/** A held-open door is an open doorway to the eye (rules rev 15): REMOVE + * LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and + * the table holds doors open precisely to cast back through them. */ +function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard { + if ((state.config.deckRev ?? 1) < 15 || state.heldDoors.length === 0) return board; + const edges = { ...board.edges }; + for (const h of state.heldDoors) delete edges[h.key]; + return { ...board, edges }; +} + /** LOS including square-filling blockers. */ export function gameLos(state: GameState, from: Cell, to: Cell): boolean { - return sightBetween(boardView(state), from, to, losBlockers(state)); + return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state)); } /** Parse an edge key back into its north/west cell and side. */ @@ -404,7 +414,7 @@ function casterLos( to: Cell, events: GameEvent[] = [], ): boolean { - const board = perceivedBoard(state, events, caster.id, { from, to }); + const board = openHeldDoors(state, perceivedBoard(state, events, caster.id, { from, to })); const blockers = losBlockers(state); if (sightBetween(board, from, to, blockers)) return true; if (!displays(caster, "visionstone")) return false; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 54edb59..1f53e3a 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -173,6 +173,13 @@ export function sightedCellsFor(view: GameView): Set { const out = new Set(); const me = view.players.find((p) => p.id === view.you); if (!me) return out; + // Held-open doors are open doorways to the eye (rules rev 15). + let board = view.board; + if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) { + const edges = { ...board.edges }; + for (const k of view.heldDoorEdges) delete edges[k]; + board = { ...board, edges }; + } const blockers: Record = {}; for (const [key, content] of Object.entries(view.squareContents)) { if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true; @@ -182,9 +189,9 @@ export function sightedCellsFor(view: GameView): Set { blockers[`${p.position.x},${p.position.y}`] = true; } } - for (const key of Object.keys(view.board.cells)) { + for (const key of Object.keys(board.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; - if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key); + if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); } return out; } diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index b88b89c..f69187a 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -409,3 +409,68 @@ describe("holding the door open (Pick Lock / Master Key)", () => { expect(released).toBe(true); }); }); + +describe("a held door is an open doorway to the eye (rules rev 15)", () => { + function sightRig(deckRev: number) { + let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev }); + 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 holder = activePlayer(state); + const pursuer = state.players.find((p) => p.id !== holder.id)!; + holder.position = { ...cell }; + pursuer.position = neighbor(cell, side); + return { state, cell, side, holder: holder.id, pursuer: pursuer.id }; + } + throw new Error("setup: seed 42 grew a maze with no doors"); + } + + it("rev 15: the holder blasts the pursuer through the doorway", () => { + let { state, cell, side, holder, pursuer } = sightRig(15); + const pick = giveCard(state, holder, "pick-lock"); + state = must(state, holder, { + type: "cast", instanceId: pick.instanceId, + target: { kind: "edge", cell, side }, params: { hold: true }, + }); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const lifeBefore = state.players.find((p) => p.id === pursuer)!.life; + state = must(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + state = must(state, pursuer, { type: "pass" }); + expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore); + }); + + it("an unheld unlocked door still blocks sight", () => { + let { state, cell, side, holder, pursuer } = sightRig(15); + const pick = giveCard(state, holder, "pick-lock"); + state = must(state, holder, { + type: "cast", instanceId: pick.instanceId, + target: { kind: "edge", cell, side }, + }); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const refused = applyCommand(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + expect(refused.ok).toBe(false); + }); + + it("earlier revisions keep the blocked sightline, hold or no", () => { + let { state, cell, side, holder, pursuer } = sightRig(14); + const pick = giveCard(state, holder, "pick-lock"); + state = must(state, holder, { + type: "cast", instanceId: pick.instanceId, + target: { kind: "edge", cell, side }, params: { hold: true }, + }); + const fb = giveCard(state, holder, "fireball", "FB", 1); + const refused = applyCommand(state, holder, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, + }); + expect(refused.ok).toBe(false); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 0ffddf8..76ebe2b 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 14; +const RULES_REV = 15; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index edec871..f3b76d6 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 14, + deckRev: 15, }; const { state, events } = createGame(config); for (const e of events) {