diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 2a8816a..fe60b8c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -226,7 +226,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 2; +export const CURRENT_RULES_REV = 3; export interface GameConfig { playerIds: PlayerId[]; @@ -242,6 +242,9 @@ export interface GameConfig { * Rev 2: a wizard beside a door they can open (lock removed, door * unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through * the doorway; the hallway behind them still cannot. + * Rev 3: VISIONSTONE pierces its one wall for every sight the game + * asks of its bearer — creations and utility spells included — not + * only direct attacks. */ deckRev?: number; } @@ -378,8 +381,37 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse } /** LOS including square-filling blockers. */ +function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { + const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); + const blockers = losBlockers(state); + if (sightBetween(board, from, to, blockers)) return true; + if (!stone) return false; + const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; + if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false; + for (const key of Object.keys(board.edges)) { + if ((board.edges[key] ?? "open") === "open") continue; + const edges = { ...board.edges }; + delete edges[key]; + if (sightBetween({ ...board, edges }, from, to, blockers)) return true; + } + return false; +} + +/** LOS including square-filling blockers. VISIONSTONE is the bearer's + * sight wherever sight is asked of them — creations, dispels, utility + * spells — not just attacks: one wall or door, any type, falls away. + * Safe at every revision: widening what a cast may target never changes + * how a recorded command replays. */ export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { - return sightBetween(doorsAjar(state, viewerId, openedDoors(state, boardView(state))), from, to, losBlockers(state)); + return losWith(state, from, to, viewerId, true); +} + +/** The eye of an armed ambush. Springing is decided mid-move and lands + * in the ledger's flow, so this sight is frozen per game: before rev 3 + * an ambush never looked through its owner's VISIONSTONE, and stored + * games must replay that blindness. */ +function ambushLos(state: GameState, owner: PlayerState, from: Cell, to: Cell): boolean { + return losWith(state, from, to, owner.id, (state.config.deckRev ?? 1) >= 3); } /** Parse an edge key back into its north/west cell and side. */ @@ -5167,8 +5199,8 @@ function checkAmbushes( sprung = context.pickedUpTreasure === true; } else if (context.movedFrom) { if (ambush.trigger.kind === "los") { - const before = gameLos(state, owner.position, context.movedFrom, owner.id); - const now = gameLos(state, owner.position, actor.position, owner.id); + const before = ambushLos(state, owner, owner.position, context.movedFrom); + const now = ambushLos(state, owner, owner.position, actor.position); sprung = now && !before; } else if (ambush.trigger.kind === "near") { const dist = (c: Cell) => @@ -5180,7 +5212,7 @@ function checkAmbushes( // The committed spell must be legal right now, or the ambush stays armed. const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect; - if (fx.requiresLos && !gameLos(state, owner.position, actor.position, owner.id)) continue; + if (fx.requiresLos && !ambushLos(state, owner, owner.position, actor.position)) continue; state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 7d61eb1..6edee4b 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -527,6 +527,25 @@ describe("the VISIONSTONE pierces one wall", () => { expect(cast.ok).toBe(true); }); + it("its bearer plants a THORNBUSH through the wall", () => { + const { state, a } = stoneRig(); + // The square past the wall must be bare ground for a creation. + const cell = { x: 2, y: 5 }; + for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 }; + const bush = giveCard(state, a.id, "thornbush", "TB"); + const refused = applyCommand(state, a.id, { + type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell }, + }); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.error).toContain("line of sight"); + const stone = giveCard(state, a.id, "visionstone", "VS", 1); + a.displayed.push(stone.instanceId); + const cast = applyCommand(state, a.id, { + type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell }, + }); + expect(cast.ok).toBe(true); + }); + it("the client's sighted squares agree with the stone", () => { const { state, a, b } = stoneRig(); expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);