Warp sight honors "as though they were adjacent" — diagonals included

Playtesting caught the gap with a sticky wand: a target visible
through the opening but one square off the corridor axis was refused.
The colinear-only model was stricter than the rulebook, whose actual
instruction is to treat the connected edges as adjacent boards with a
straight center-to-center line — and a straight line through a
one-cell opening may run diagonally, exactly as through a doorway.

hasWarpLineOfSight now abuts the far side virtually at the mouth
(rotating when the pairing turns a corner, as aisle warps do), finds
where the center-to-center line crosses the rim, requires that
crossing to fall strictly within the open mouth (corner-grazing stays
blocked — the strict reading), and checks the two legs against real
walls on each side of the seam. hasLineOfSight's core became a shared
segmentClear to serve both legs. The colinear corridor cases remain
as the special case they always were; a new test pins that off-axis
squares are visible through mouths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 15:20:46 -04:00
co-authored by Claude Fable 5
parent 53edf9734e
commit 8af8962330
3 changed files with 198 additions and 28 deletions
+73 -28
View File
@@ -268,14 +268,23 @@ export function hasLineOfSight(
blockedCells?: Record<string, true>,
): boolean {
if (cellKey(from) === cellKey(to)) return true;
// Centers of cells: (x + 0.5, y + 0.5).
const x0 = from.x + 0.5, y0 = from.y + 0.5;
const x1 = to.x + 0.5, y1 = to.y + 0.5;
return segmentClear(
board, from.x + 0.5, from.y + 0.5, to.x + 0.5, to.y + 0.5,
blockedCells, [cellKey(from), cellKey(to)],
);
}
/** Is this raw sight segment unobstructed by walls or solid cells? */
function segmentClear(
board: AssembledBoard,
x0: number, y0: number, x1: number, y1: number,
blockedCells: Record<string, true> | undefined,
skipCells: string[],
): boolean {
if (blockedCells) {
for (const key of Object.keys(blockedCells)) {
if (skipCells.includes(key)) continue;
const [bx, by] = key.split(",").map(Number) as [number, number];
if ((bx === from.x && by === from.y) || (bx === to.x && by === to.y)) continue;
// The sight line is blocked if it crosses any side of the solid cell.
const sides: [number, number, number, number][] = [
[bx, by, bx + 1, by],
@@ -334,12 +343,14 @@ function onSegment(ax: number, ay: number, bx: number, by: number, px: number, p
}
/**
* 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.
* 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 edges the same way. So the far side is
* virtually abutted at the mouth (rotated if the pairing turns a corner,
* as the aisle warps do), and the center-to-center line must pass through
* the one-cell opening: diagonals through the gap are as legal as they are
* through any doorway.
*/
export function hasWarpLineOfSight(
board: AssembledBoard,
@@ -347,31 +358,65 @@ export function hasWarpLineOfSight(
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
const DIR: Record<Side, { x: number; y: number }> = {
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
};
const EPS = 1e-9;
for (const w of board.warps) {
const mouthA = w.from.cell;
const out = w.from.side;
const sideA = 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.
// A filled mouth chokes 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;
// Rotation taking the far board's inward direction onto sideA.
const u = DIR[inward], v = DIR[sideA];
const cos = u.x * v.x + u.y * v.y;
const sin = u.x * v.y - u.y * v.x;
const centerB = { x: mouthB.x + 0.5, y: mouthB.y + 0.5 };
const nA = neighbor(mouthA, sideA);
const anchor = { x: nA.x + 0.5, y: nA.y + 0.5 };
const T = (p: { x: number; y: number }) => ({
x: cos * (p.x - centerB.x) - sin * (p.y - centerB.y) + anchor.x,
y: sin * (p.x - centerB.x) + cos * (p.y - centerB.y) + anchor.y,
});
const Tinv = (p: { x: number; y: number }) => ({
x: cos * (p.x - anchor.x) + sin * (p.y - anchor.y) + centerB.x,
y: -sin * (p.x - anchor.x) + cos * (p.y - anchor.y) + centerB.y,
});
const c0 = { x: from.x + 0.5, y: from.y + 0.5 };
const c1 = T({ x: to.x + 0.5, y: to.y + 0.5 });
// The line must cross the rim through the open mouth of A.
let t: number, off: number, P: { x: number; y: number };
if (sideA === "E" || sideA === "W") {
const rimX = sideA === "E" ? mouthA.x + 1 : mouthA.x;
if (Math.abs(c1.x - c0.x) < EPS) continue;
t = (rimX - c0.x) / (c1.x - c0.x);
const y = c0.y + t * (c1.y - c0.y);
off = y - mouthA.y;
P = { x: rimX, y };
} else {
const rimY = sideA === "S" ? mouthA.y + 1 : mouthA.y;
if (Math.abs(c1.y - c0.y) < EPS) continue;
t = (rimY - c0.y) / (c1.y - c0.y);
const x = c0.x + t * (c1.x - c0.x);
off = x - mouthA.x;
P = { x, y: rimY };
}
if (t <= EPS || t >= 1 - EPS) continue;
// Grazing the mouth's corners counts as blocked — strict reading.
if (off <= EPS || off >= 1 - EPS) continue;
const Pfar = Tinv(P);
if (
hasLineOfSight(board, from, mouthA, blockedCells) &&
hasLineOfSight(board, mouthB, to, blockedCells)
segmentClear(board, c0.x, c0.y, P.x, P.y, blockedCells,
[cellKey(from), cellKey(mouthA)]) &&
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
[cellKey(to), cellKey(mouthB)])
) {
return true;
}
+27
View File
@@ -236,3 +236,30 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
expect(bent(r.state.board.warps).length).toBe(0); // "only opposite board edges connect"
});
});
describe("sight through warps treats the boards as adjacent", () => {
const twoSector: SectorPlacement[] = [
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
];
it("diagonal lines pass through the one-cell mouth like any doorway", () => {
const board = assembleBoard(twoSector);
// From SOME warp mouth, at least one square off the straight corridor
// axis must be visible through the opening — the adjacency promise.
let offAxis = 0;
for (const w of board.warps) {
const mouth = w.from.cell;
const axisVertical = w.from.side === "N" || w.from.side === "S";
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const onAxis = axisVertical ? x === mouth.x : y === mouth.y;
if (onAxis) continue;
if (!hasLineOfSight(board, mouth, { x, y }) && sightBetween(board, mouth, { x, y })) {
offAxis++;
}
}
}
expect(offAxis).toBeGreaterThan(0);
});
});