Sight travels through the wraparound openings
"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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fce3adc340
commit
64454ffc3e
@@ -18,6 +18,10 @@ export interface SectorPlacement {
|
|||||||
rotation: Rotation;
|
rotation: Rotation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function opposite(s: Side): Side {
|
||||||
|
return s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E";
|
||||||
|
}
|
||||||
|
|
||||||
export interface Warp {
|
export interface Warp {
|
||||||
/** Leaving this cell through this side of the map... */
|
/** Leaving this cell through this side of the map... */
|
||||||
from: { cell: Cell; side: Side };
|
from: { cell: Cell; side: Side };
|
||||||
@@ -198,7 +202,6 @@ export function assembleBoard(
|
|||||||
case "E": return toGlobal(origin, 3, SECTOR);
|
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) {
|
if (options.warpPairs) {
|
||||||
for (const [a, b] of 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`
|
* wall/door/firewall edges the segment crosses and by any `blockedCells`
|
||||||
* (solid stone, thornbushes) it passes through. Grazing a wall endpoint
|
* (solid stone, thornbushes) it passes through. Grazing a wall endpoint
|
||||||
* (passing exactly through a corner adjacent to a wall) counts as blocked —
|
* (passing exactly through a corner adjacent to a wall) counts as blocked —
|
||||||
* strict reading; revisit against FAQ rulings if needed. LOS through
|
* strict reading. Direct sight only; `sightBetween` adds the wraparound
|
||||||
* wraparound openings is not yet modeled (TODO).
|
* openings.
|
||||||
*/
|
*/
|
||||||
export function hasLineOfSight(
|
export function hasLineOfSight(
|
||||||
board: AssembledBoard,
|
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)
|
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<string, true>,
|
||||||
|
): 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<string, true>,
|
||||||
|
): boolean {
|
||||||
|
return hasLineOfSight(board, from, to, blockedCells) ||
|
||||||
|
hasWarpLineOfSight(board, from, to, blockedCells);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
cellKey,
|
cellKey,
|
||||||
edgeKey,
|
edgeKey,
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
|
sightBetween,
|
||||||
neighbor,
|
neighbor,
|
||||||
stepTarget,
|
stepTarget,
|
||||||
} from "./board";
|
} from "./board";
|
||||||
@@ -290,7 +291,7 @@ export function losBlockers(state: GameState): Record<string, true> {
|
|||||||
|
|
||||||
/** LOS including square-filling blockers. */
|
/** LOS including square-filling blockers. */
|
||||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
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. */
|
/** Parse an edge key back into its north/west cell and side. */
|
||||||
@@ -368,13 +369,13 @@ function casterLos(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const board = perceivedBoard(state, events, caster.id, { from, to });
|
const board = perceivedBoard(state, events, caster.id, { from, to });
|
||||||
const blockers = losBlockers(state);
|
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;
|
if (!displays(caster, "visionstone")) return false;
|
||||||
for (const key of Object.keys(board.edges)) {
|
for (const key of Object.keys(board.edges)) {
|
||||||
if ((board.edges[key] ?? "open") === "open") continue;
|
if ((board.edges[key] ?? "open") === "open") continue;
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
delete edges[key];
|
delete edges[key];
|
||||||
if (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true;
|
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
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 {
|
function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean {
|
||||||
const n = neighbor(cell, side);
|
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 {
|
function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// The server sends this after every state change; clients never see the
|
// The server sends this after every state change; clients never see the
|
||||||
// deck order or other players' hands.
|
// deck order or other players' hands.
|
||||||
|
|
||||||
import { hasLineOfSight, type AssembledBoard } from "./board";
|
import { sightBetween, type AssembledBoard } from "./board";
|
||||||
import { type CardInstance } from "./cards";
|
import { type CardInstance } from "./cards";
|
||||||
import {
|
import {
|
||||||
boardView,
|
boardView,
|
||||||
@@ -164,7 +164,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
}
|
}
|
||||||
for (const key of Object.keys(view.board.cells)) {
|
for (const key of Object.keys(view.board.cells)) {
|
||||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
assembleBoard,
|
assembleBoard,
|
||||||
edgeState,
|
edgeState,
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
|
sightBetween,
|
||||||
layoutIds,
|
layoutIds,
|
||||||
stepTarget,
|
stepTarget,
|
||||||
type SectorPlacement,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user