diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts index 6510000..48ebc44 100644 --- a/packages/engine/src/board.ts +++ b/packages/engine/src/board.ts @@ -18,6 +18,10 @@ export interface SectorPlacement { rotation: Rotation; } +export function opposite(s: Side): Side { + return s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E"; +} + export interface Warp { /** Leaving this cell through this side of the map... */ from: { cell: Cell; side: Side }; @@ -198,7 +202,6 @@ export function assembleBoard( case "E": return toGlobal(origin, 3, SECTOR); } }; - const opposite = (s: Side): Side => (s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E"); if (options.warpPairs) { for (const [a, b] of options.warpPairs) { @@ -255,8 +258,8 @@ export function stepTarget( * wall/door/firewall edges the segment crosses and by any `blockedCells` * (solid stone, thornbushes) it passes through. Grazing a wall endpoint * (passing exactly through a corner adjacent to a wall) counts as blocked — - * strict reading; revisit against FAQ rulings if needed. LOS through - * wraparound openings is not yet modeled (TODO). + * strict reading. Direct sight only; `sightBetween` adds the wraparound + * openings. */ export function hasLineOfSight( board: AssembledBoard, @@ -329,3 +332,60 @@ function onSegment(ax: number, ay: number, bx: number, by: number, px: number, p Math.min(ay, by) <= py && py <= Math.max(ay, by) ); } + +/** + * Straight-corridor sight through a wraparound opening. Rulebook: "If + * casting a spell, or checking line of sight through the AUTO WARP, treat + * it as a straight line, and the two connected boards as though they were + * adjacent" — and the lettered openings reconnect board edges the same way + * ("you will reenter at point A"). The line must run down the corridor, out + * one mouth, and straight in through the paired mouth: no diagonals. + */ +export function hasWarpLineOfSight( + board: AssembledBoard, + from: Cell, + to: Cell, + blockedCells?: Record, +): boolean { + for (const w of board.warps) { + const mouthA = w.from.cell; + const out = w.from.side; + const mouthB = w.to.cell; + const inward = opposite(w.to.side); + // The viewer stands in mouth A's corridor, on the interior side. + const fromAligned = out === "N" || out === "S" ? from.x === mouthA.x : from.y === mouthA.y; + if (!fromAligned) continue; + const fromInterior = + out === "N" ? from.y >= mouthA.y : out === "S" ? from.y <= mouthA.y : + out === "E" ? from.x <= mouthA.x : from.x >= mouthA.x; + if (!fromInterior) continue; + // The target sits in mouth B's corridor, from the mouth inward. + const toAligned = inward === "N" || inward === "S" ? to.x === mouthB.x : to.y === mouthB.y; + if (!toAligned) continue; + const toInterior = + inward === "N" ? to.y <= mouthB.y : inward === "S" ? to.y >= mouthB.y : + inward === "E" ? to.x >= mouthB.x : to.x <= mouthB.x; + if (!toInterior) continue; + // A filled mouth blocks the tunnel unless the viewer/target IS the mouth. + if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue; + if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue; + if ( + hasLineOfSight(board, from, mouthA, blockedCells) && + hasLineOfSight(board, mouthB, to, blockedCells) + ) { + return true; + } + } + return false; +} + +/** The game's full line-of-sight check: direct, or through a wraparound opening. */ +export function sightBetween( + board: AssembledBoard, + from: Cell, + to: Cell, + blockedCells?: Record, +): boolean { + return hasLineOfSight(board, from, to, blockedCells) || + hasWarpLineOfSight(board, from, to, blockedCells); +} diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a77c528..a3fa62d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -21,6 +21,7 @@ import { cellKey, edgeKey, hasLineOfSight, + sightBetween, neighbor, stepTarget, } from "./board"; @@ -290,7 +291,7 @@ export function losBlockers(state: GameState): Record { /** LOS including square-filling blockers. */ export function gameLos(state: GameState, from: Cell, to: Cell): boolean { - return hasLineOfSight(boardView(state), from, to, losBlockers(state)); + return sightBetween(boardView(state), from, to, losBlockers(state)); } /** Parse an edge key back into its north/west cell and side. */ @@ -368,13 +369,13 @@ function casterLos( ): boolean { const board = perceivedBoard(state, events, caster.id, { from, to }); const blockers = losBlockers(state); - if (hasLineOfSight(board, from, to, blockers)) return true; + if (sightBetween(board, from, to, blockers)) return true; if (!displays(caster, "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 (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true; + if (sightBetween({ ...board, edges }, from, to, blockers)) return true; } return false; } @@ -2752,7 +2753,7 @@ function dragToward(state: GameState, target: PlayerState, dest: Cell): void { function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean { const n = neighbor(cell, side); - return hasLineOfSight(board, from, cell) || hasLineOfSight(board, from, n); + return sightBetween(board, from, cell) || sightBetween(board, from, n); } function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean { diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 28b488f..5920efa 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,7 +2,7 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { hasLineOfSight, type AssembledBoard } from "./board"; +import { sightBetween, type AssembledBoard } from "./board"; import { type CardInstance } from "./cards"; import { boardView, @@ -164,7 +164,7 @@ export function sightedCellsFor(view: GameView): Set { } for (const key of Object.keys(view.board.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; - if (hasLineOfSight(view.board, me.position, { x, y }, blockers)) out.add(key); + if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key); } return out; } diff --git a/packages/engine/test/board.test.ts b/packages/engine/test/board.test.ts index bfc0825..8b23f48 100644 --- a/packages/engine/test/board.test.ts +++ b/packages/engine/test/board.test.ts @@ -3,6 +3,7 @@ import { assembleBoard, edgeState, hasLineOfSight, + sightBetween, layoutIds, stepTarget, type SectorPlacement, @@ -136,3 +137,39 @@ describe("player counts 2-6", () => { } }); }); + +describe("line of sight through wraparound openings", () => { + const twoSector: SectorPlacement[] = [ + { boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }, + { boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 }, + ]; + + it("sees straight down the corridor, out one mouth and in the other", () => { + const board = assembleBoard(twoSector); + // (2,0) N wraps to (2,9): the mouths see each other through the join. + expect(hasLineOfSight(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false); + expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(true); + expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(true); + }); + + it("demands a straight corridor: off-axis viewers see nothing", () => { + const board = assembleBoard(twoSector); + expect(sightBetween(board, { x: 1, y: 0 }, { x: 2, y: 9 })).toBe(false); + expect(sightBetween(board, { x: 3, y: 1 }, { x: 2, y: 9 })).toBe(false); + }); + + it("walls inside either corridor still block the warp sight", () => { + const board = assembleBoard(twoSector); + // Layout A: the wall between (2,0) and (2,1)'s row (home column sight is + // cut above row 2 — pinned earlier) blocks a deeper viewer's warp sight. + const deepViewer = { x: 2, y: 2 }; + expect(hasLineOfSight(board, deepViewer, { x: 2, y: 0 })).toBe(false); + expect(sightBetween(board, deepViewer, { x: 2, y: 9 })).toBe(false); + }); + + it("a filled mouth chokes the tunnel", () => { + const board = assembleBoard(twoSector); + const stone = { "2,9": true as const }; + expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false); + }); +});