From 64454ffc3e167a86ebd02bef358fd2f78c9412c1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 12:43:57 -0400 Subject: [PATCH] Sight travels through the wraparound openings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 edges the same way. hasWarpLineOfSight models it: the line must run straight down the corridor, out one mouth, and in through the paired mouth, with both corridor legs clear and neither mouth filled solid. The new sightBetween (direct or through-a-warp) is now the game's LOS check everywhere — spells, ambush triggers, Around The Corner, the visionstone sweep, and the targeting dim on the client. Co-Authored-By: Claude Fable 5 --- packages/engine/src/board.ts | 66 ++++++++++++++++++++++++++++-- packages/engine/src/game.ts | 9 ++-- packages/engine/src/view.ts | 4 +- packages/engine/test/board.test.ts | 37 +++++++++++++++++ 4 files changed, 107 insertions(+), 9 deletions(-) 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); + }); +});