diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 99860b4..0e72153 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -200,6 +200,26 @@ export function sightedCellsFor(view: GameView): Set { const [x, y] = key.split(",").map(Number) as [number, number]; if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); } + // VISIONSTONE lets its bearer see through exactly one wall or door — + // any one — so a square is also sighted if removing a single edge + // reveals it, mirroring the engine's casterLos. + if (me.displayed.some((c) => c.cardId === "visionstone")) { + const unseen = Object.keys(board.cells).filter((k) => !out.has(k)); + for (const edgeK of Object.keys(board.edges)) { + if (unseen.length === 0) break; + if ((board.edges[edgeK] ?? "open") === "open") continue; + const edges = { ...board.edges }; + delete edges[edgeK]; + const opened = { ...board, edges }; + for (let i = unseen.length - 1; i >= 0; i--) { + const [x, y] = unseen[i]!.split(",").map(Number) as [number, number]; + if (sightBetween(opened, me.position, { x, y }, blockers)) { + out.add(unseen[i]!); + unseen.splice(i, 1); + } + } + } + } return out; } diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 35937a5..7d61eb1 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { viewFor } from "../src/view"; +import { sightedCellsFor, viewFor } from "../src/view"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; /** Test surgery: put a specific card into a player's hand (swapping one out). */ @@ -500,6 +500,42 @@ describe("zero-damage utility attacks", () => { }); }); +describe("the VISIONSTONE pierces one wall", () => { + function stoneRig() { + let { state } = newGame(); + state = toRound2(state); + const a = activePlayer(state); + const b = state.players.find((p) => p.id !== a.id)!; + a.position = { x: 2, y: 4 }; + b.position = { x: 2, y: 5 }; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall"; + return { state, a, b }; + } + + it("its bearer casts an attack straight through the wall", () => { + const { state, a, b } = stoneRig(); + const fb = giveCard(state, a.id, "fireball", "FB", 1); + const refused = applyCommand(state, a.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id }, + }); + expect(refused.ok).toBe(false); + const stone = giveCard(state, a.id, "visionstone", "VS"); + a.displayed.push(stone.instanceId); + const cast = applyCommand(state, a.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id }, + }); + 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); + const stone = giveCard(state, a.id, "visionstone", "VS"); + a.displayed.push(stone.instanceId); + expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true); + }); +}); + describe("teleport wraps the maze's rim", () => { it("four spaces counted through a warp mouth reach the far side", () => { let { state } = newGame();