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>
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
// Board assembly: sector layouts from data/boards.json are rotated and placed
|
|
// into one global grid. Cells are {x, y}, 0-indexed, x → east, y → south.
|
|
// Edges between cells are 'open', 'wall', or 'door'; sector perimeters become
|
|
// walls except the mid-edge openings, which either join two sectors or become
|
|
// wraparound warp connections at the outer boundary.
|
|
|
|
import boardsData from "../data/boards.json";
|
|
|
|
export type Cell = { readonly x: number; readonly y: number };
|
|
export type Side = "N" | "S" | "E" | "W";
|
|
export type EdgeState = "open" | "wall" | "door" | "firewall";
|
|
export type Rotation = 0 | 90 | 180 | 270;
|
|
|
|
export interface SectorPlacement {
|
|
boardId: string;
|
|
/** Top-left cell of the sector in global coordinates (multiples of 5). */
|
|
origin: Cell;
|
|
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 };
|
|
/** ...you arrive at this cell (entering through `side`). */
|
|
to: { cell: Cell; side: Side };
|
|
}
|
|
|
|
export interface AssembledBoard {
|
|
width: number;
|
|
height: number;
|
|
placements: SectorPlacement[];
|
|
/** Edge states keyed by edgeKey(). Only non-open edges are stored. */
|
|
edges: Record<string, EdgeState>;
|
|
/** Cells that are part of the map (all cells inside placed sectors). */
|
|
cells: Record<string, true>;
|
|
warps: Warp[];
|
|
/** Per player-sector info, in placement order. */
|
|
homes: Cell[];
|
|
treasureSpaces: Cell[][];
|
|
}
|
|
|
|
const SECTOR = 5;
|
|
|
|
export function cellKey(c: Cell): string {
|
|
return `${c.x},${c.y}`;
|
|
}
|
|
|
|
/**
|
|
* Canonical key for the edge between cell c and its neighbor on `side`.
|
|
* Every edge is expressed from its north/west cell so both cells agree.
|
|
*/
|
|
export function edgeKey(c: Cell, side: Side): string {
|
|
switch (side) {
|
|
case "E": return `V:${c.x},${c.y}`;
|
|
case "W": return `V:${c.x - 1},${c.y}`;
|
|
case "S": return `H:${c.x},${c.y}`;
|
|
case "N": return `H:${c.x},${c.y - 1}`;
|
|
}
|
|
}
|
|
|
|
export function neighbor(c: Cell, side: Side): Cell {
|
|
switch (side) {
|
|
case "N": return { x: c.x, y: c.y - 1 };
|
|
case "S": return { x: c.x, y: c.y + 1 };
|
|
case "E": return { x: c.x + 1, y: c.y };
|
|
case "W": return { x: c.x - 1, y: c.y };
|
|
}
|
|
}
|
|
|
|
export const SIDES: readonly Side[] = ["N", "S", "E", "W"];
|
|
|
|
interface LayoutData {
|
|
id: string;
|
|
homeSpace: number[];
|
|
treasureSpaces: number[][];
|
|
walls: { cell: number[]; side: string }[];
|
|
doors: { cell: number[]; side: string }[];
|
|
}
|
|
|
|
const layouts: Map<string, LayoutData> = new Map(
|
|
(boardsData as { boards: LayoutData[] }).boards.map((b) => [b.id, b]),
|
|
);
|
|
|
|
export function layoutIds(): string[] {
|
|
return [...layouts.keys()];
|
|
}
|
|
|
|
/** Rotate a sector-local cell (1-indexed [row, col]) within a 5x5 grid. */
|
|
function rotateCell(row: number, col: number, rotation: Rotation): [number, number] {
|
|
switch (rotation) {
|
|
case 0: return [row, col];
|
|
case 90: return [col, SECTOR + 1 - row]; // clockwise
|
|
case 180: return [SECTOR + 1 - row, SECTOR + 1 - col];
|
|
case 270: return [SECTOR + 1 - col, row];
|
|
}
|
|
}
|
|
|
|
function rotateSide(side: Side, rotation: Rotation): Side {
|
|
const order: Side[] = ["N", "E", "S", "W"];
|
|
const idx = order.indexOf(side);
|
|
return order[(idx + rotation / 90) % 4]!;
|
|
}
|
|
|
|
/** Convert sector-local (1-indexed [row, col]) to a global cell. */
|
|
function toGlobal(origin: Cell, row: number, col: number): Cell {
|
|
return { x: origin.x + col - 1, y: origin.y + row - 1 };
|
|
}
|
|
|
|
/** An outer-boundary opening, identified by placement index and map side. */
|
|
export type OpeningRef = { sector: number; side: Side };
|
|
|
|
export interface AssemblyOptions {
|
|
/**
|
|
* Explicit wraparound pairings (each pair is bidirectional). When omitted,
|
|
* openings pair straight across the map — correct for the 4p square; the
|
|
* standard 2p column crosses its side openings and must pass pairs.
|
|
*/
|
|
warpPairs?: [OpeningRef, OpeningRef][];
|
|
}
|
|
|
|
/**
|
|
* Assemble sectors into one board. Interior walls and doors come from the
|
|
* layout data; sector perimeters become walls except the centered mid-edge
|
|
* opening on each side. Openings between two adjacent sectors line up (they
|
|
* are always centered) and stay open; openings on the outer boundary become
|
|
* wraparound warps ("leave at A, re-enter at A").
|
|
*/
|
|
export function assembleBoard(
|
|
placements: SectorPlacement[],
|
|
options: AssemblyOptions = {},
|
|
): AssembledBoard {
|
|
const edges: Record<string, EdgeState> = {};
|
|
const cells: Record<string, true> = {};
|
|
const homes: Cell[] = [];
|
|
const treasureSpaces: Cell[][] = [];
|
|
|
|
let width = 0;
|
|
let height = 0;
|
|
|
|
for (const p of placements) {
|
|
const layout = layouts.get(p.boardId);
|
|
if (!layout) throw new Error(`unknown board layout: ${p.boardId}`);
|
|
if (p.origin.x % SECTOR !== 0 || p.origin.y % SECTOR !== 0) {
|
|
throw new Error("sector origins must be multiples of 5");
|
|
}
|
|
width = Math.max(width, p.origin.x + SECTOR);
|
|
height = Math.max(height, p.origin.y + SECTOR);
|
|
|
|
for (let r = 1; r <= SECTOR; r++) {
|
|
for (let c = 1; c <= SECTOR; c++) {
|
|
cells[cellKey(toGlobal(p.origin, r, c))] = true;
|
|
}
|
|
}
|
|
|
|
const [hr, hc] = rotateCell(layout.homeSpace[0]!, layout.homeSpace[1]!, p.rotation);
|
|
homes.push(toGlobal(p.origin, hr, hc));
|
|
treasureSpaces.push(
|
|
layout.treasureSpaces.map((t) => {
|
|
const [tr, tc] = rotateCell(t[0]!, t[1]!, p.rotation);
|
|
return toGlobal(p.origin, tr, tc);
|
|
}),
|
|
);
|
|
|
|
const place = (cellRC: number[], side: string, state: EdgeState) => {
|
|
// Rotating a cell+side pair: rotate the cell, rotate the side.
|
|
const [r0, c0] = cellRC as [number, number];
|
|
const [r, c] = rotateCell(r0, c0, p.rotation);
|
|
const s = rotateSide(side as Side, p.rotation);
|
|
edges[edgeKey(toGlobal(p.origin, r, c), s)] = state;
|
|
};
|
|
for (const w of layout.walls) place(w.cell, w.side, "wall");
|
|
for (const d of layout.doors) place(d.cell, d.side, "door");
|
|
|
|
// Perimeter: wall everywhere except the centered opening (position 3) on
|
|
// each side. Openings are rotation-invariant because they are centered.
|
|
for (let i = 1; i <= SECTOR; i++) {
|
|
if (i !== 3) {
|
|
edges[edgeKey(toGlobal(p.origin, 1, i), "N")] = "wall";
|
|
edges[edgeKey(toGlobal(p.origin, SECTOR, i), "S")] = "wall";
|
|
edges[edgeKey(toGlobal(p.origin, i, 1), "W")] = "wall";
|
|
edges[edgeKey(toGlobal(p.origin, i, SECTOR), "E")] = "wall";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wraparound warps. An opening that faces another sector is a corridor, not
|
|
// a warp. The remaining outer-boundary openings pair per options.warpPairs
|
|
// (from the setup diagram's letters) or, by default, straight across the
|
|
// map — including the aisle-warp corner arcs, which are just warp pairs.
|
|
const warps: Warp[] = [];
|
|
const inMap = (c: Cell) => cells[cellKey(c)] === true;
|
|
const openingCell = (sectorIndex: number, side: Side): Cell => {
|
|
const origin = placements[sectorIndex]!.origin;
|
|
switch (side) {
|
|
case "N": return toGlobal(origin, 1, 3);
|
|
case "S": return toGlobal(origin, SECTOR, 3);
|
|
case "W": return toGlobal(origin, 3, 1);
|
|
case "E": return toGlobal(origin, 3, SECTOR);
|
|
}
|
|
};
|
|
|
|
if (options.warpPairs) {
|
|
for (const [a, b] of options.warpPairs) {
|
|
const ca = openingCell(a.sector, a.side);
|
|
const cb = openingCell(b.sector, b.side);
|
|
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } });
|
|
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } });
|
|
}
|
|
} else {
|
|
for (let i = 0; i < placements.length; i++) {
|
|
for (const side of SIDES) {
|
|
const cell = openingCell(i, side);
|
|
if (inMap(neighbor(cell, side))) continue; // joins an adjacent sector
|
|
let probe = cell;
|
|
while (inMap(neighbor(probe, opposite(side)))) probe = neighbor(probe, opposite(side));
|
|
warps.push({ from: { cell, side }, to: { cell: probe, side: opposite(side) } });
|
|
}
|
|
}
|
|
}
|
|
|
|
return { width, height, placements, edges, cells, warps, homes, treasureSpaces };
|
|
}
|
|
|
|
export function edgeState(board: AssembledBoard, c: Cell, side: Side): EdgeState {
|
|
return board.edges[edgeKey(c, side)] ?? "open";
|
|
}
|
|
|
|
export function findWarp(board: AssembledBoard, c: Cell, side: Side): Warp | undefined {
|
|
return board.warps.find((w) => cellKey(w.from.cell) === cellKey(c) && w.from.side === side);
|
|
}
|
|
|
|
/**
|
|
* Where does one step from `c` toward `side` lead?
|
|
* - blocked: a wall or (locked) door is in the way
|
|
* - normal step to the adjacent cell
|
|
* - warp step through a map-edge opening
|
|
*/
|
|
export function stepTarget(
|
|
board: AssembledBoard,
|
|
c: Cell,
|
|
side: Side,
|
|
): { kind: "blocked"; by: EdgeState } | { kind: "step" | "warp"; to: Cell } {
|
|
const e = edgeState(board, c, side);
|
|
if (e !== "open") return { kind: "blocked", by: e };
|
|
const n = neighbor(c, side);
|
|
if (board.cells[cellKey(n)]) return { kind: "step", to: n };
|
|
const warp = findWarp(board, c, side);
|
|
if (warp) return { kind: "warp", to: warp.to.cell };
|
|
return { kind: "blocked", by: "wall" };
|
|
}
|
|
|
|
/**
|
|
* Line of sight from the center of `from` to the center of `to`, blocked by
|
|
* 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. Direct sight only; `sightBetween` adds the wraparound
|
|
* openings.
|
|
*/
|
|
export function hasLineOfSight(
|
|
board: AssembledBoard,
|
|
from: Cell,
|
|
to: Cell,
|
|
blockedCells?: Record<string, true>,
|
|
): boolean {
|
|
if (cellKey(from) === cellKey(to)) return true;
|
|
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];
|
|
// 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],
|
|
[bx, by + 1, bx + 1, by + 1],
|
|
[bx, by, bx, by + 1],
|
|
[bx + 1, by, bx + 1, by + 1],
|
|
];
|
|
if (sides.some(([ax, ay, cx, cy]) => segmentsIntersect(x0, y0, x1, y1, ax, ay, cx, cy))) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [key, state] of Object.entries(board.edges)) {
|
|
if (state === "open") continue;
|
|
// Reconstruct the wall segment for this edge.
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [ex, ey] = coords.split(",").map(Number) as [number, number];
|
|
// V:x,y = edge between (x,y) and (x+1,y): vertical segment at x+1 from y to y+1.
|
|
// H:x,y = edge between (x,y) and (x,y+1): horizontal segment at y+1 from x to x+1.
|
|
const [ax, ay, bx, by] =
|
|
kind === "V" ? [ex + 1, ey, ex + 1, ey + 1] : [ex, ey + 1, ex + 1, ey + 1];
|
|
if (segmentsIntersect(x0, y0, x1, y1, ax, ay, bx, by)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Segment intersection where touching (colinear overlap or endpoint contact) counts. */
|
|
function segmentsIntersect(
|
|
p0x: number, p0y: number, p1x: number, p1y: number,
|
|
p2x: number, p2y: number, p3x: number, p3y: number,
|
|
): boolean {
|
|
const d1 = cross(p2x, p2y, p3x, p3y, p0x, p0y);
|
|
const d2 = cross(p2x, p2y, p3x, p3y, p1x, p1y);
|
|
const d3 = cross(p0x, p0y, p1x, p1y, p2x, p2y);
|
|
const d4 = cross(p0x, p0y, p1x, p1y, p3x, p3y);
|
|
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
|
return true;
|
|
}
|
|
if (d1 === 0 && onSegment(p2x, p2y, p3x, p3y, p0x, p0y)) return true;
|
|
if (d2 === 0 && onSegment(p2x, p2y, p3x, p3y, p1x, p1y)) return true;
|
|
if (d3 === 0 && onSegment(p0x, p0y, p1x, p1y, p2x, p2y)) return true;
|
|
if (d4 === 0 && onSegment(p0x, p0y, p1x, p1y, p3x, p3y)) return true;
|
|
return false;
|
|
}
|
|
|
|
function cross(ax: number, ay: number, bx: number, by: number, px: number, py: number): number {
|
|
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
}
|
|
|
|
function onSegment(ax: number, ay: number, bx: number, by: number, px: number, py: number): boolean {
|
|
return (
|
|
Math.min(ax, bx) <= px && px <= Math.max(ax, bx) &&
|
|
Math.min(ay, by) <= py && py <= Math.max(ay, by)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
from: Cell,
|
|
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 sideA = w.from.side;
|
|
const mouthB = w.to.cell;
|
|
const inward = opposite(w.to.side);
|
|
// 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 (
|
|
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;
|
|
}
|
|
}
|
|
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);
|
|
}
|