// 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 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; /** Cells that are part of the map (all cells inside placed sectors). */ cells: Record; 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 = 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 = {}; const cells: Record = {}; 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. (L-shaped 3p aisle-warp corners are TODO.) 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); } }; 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) { 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; revisit against FAQ rulings if needed. LOS through * wraparound openings is not yet modeled (TODO). */ export function hasLineOfSight( board: AssembledBoard, from: Cell, to: Cell, blockedCells?: Record, ): 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; if (blockedCells) { for (const key of Object.keys(blockedCells)) { 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], [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) ); }