An hour of rules archaeology, summarized: DIMENSIONAL WARP denies LOS explicitly (its tokens stay sightless); the AUTO WARP describes how to trace sight across a join; and on the lettered wraparounds the texts are silent — no grant, no denial. The FAQ's Permawarp rulings even have area effects counting distance through warps. Where the texts are silent, the edition's own table breaks the tie, and the table says the open sides see. Board openings carry sight in every revision (a permissive change: every stored game's log replays clean); the brief rev-7 restriction and its legacy flag are gone, along with the sophistry that justified them. The adjacency geometry stands. The friend's sticky-wand shot through the C opening was legal after all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
442 lines
16 KiB
TypeScript
442 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 };
|
|
/** An aisle (AUTO) warp joins two edges that meet at a corner. */
|
|
aisle?: boolean;
|
|
}
|
|
|
|
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] | [OpeningRef, OpeningRef, "aisle"])[];
|
|
}
|
|
|
|
/**
|
|
* 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, kind] of options.warpPairs) {
|
|
const ca = openingCell(a.sector, a.side);
|
|
const cb = openingCell(b.sector, b.side);
|
|
const aisle = kind === "aisle" ? { aisle: true as const } : {};
|
|
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side }, ...aisle });
|
|
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side }, ...aisle });
|
|
}
|
|
} 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 — the boards "have open sides", and
|
|
* the AUTO WARP rule shows how to trace sight across a join: "treat it as
|
|
* a straight line, and the two connected boards as though they were
|
|
* adjacent". The texts never deny the lettered openings what they grant
|
|
* the aisle (only DIMENSIONAL WARP's tokens carry an explicit "no L.O.S."
|
|
* — and those are not board warps). 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);
|
|
}
|