import { describe, expect, it } from "vitest"; import { assembleBoard, edgeState, hasLineOfSight, sightBetween, layoutIds, stepTarget, type SectorPlacement, } from "../src/board"; import { buildDeck } from "../src/cards"; import { activePlayer, applyCommand, boardView, createGame, gameLos } from "../src/game"; import { createRng } from "../src/rng"; import { setupBoard } from "../src/setups"; import { giveCard } from "./helpers"; describe("card data", () => { it("basic deck is exactly 125 cards (official 6e rulebook)", () => { expect(buildDeck(["basic"]).length).toBe(125); }); it("basic + expansion1 is exactly 200 cards (125 + 75)", () => { expect(buildDeck(["basic", "expansion1"]).length).toBe(200); }); it("number cards follow the official distribution", () => { const deck = buildDeck(["basic"]); const count = (id: string) => deck.filter((c) => c.cardId === id).length; expect(count("number-2")).toBe(12); expect(count("number-3")).toBe(10); expect(count("number-4")).toBe(7); expect(count("number-5")).toBe(4); expect(count("number-6")).toBe(1); }); }); describe("board assembly", () => { it("knows all six verified layouts", () => { expect(layoutIds().sort()).toEqual( ["board-a", "board-b", "board-c", "board-d", "board-e", "board-f"].sort(), ); }); const twoSector: SectorPlacement[] = [ { boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }, { boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 }, ]; it("merges the junction between stacked sectors into a single wall with a centered corridor", () => { const board = assembleBoard(twoSector); // Junction edges y=4/5: wall everywhere except the centered opening x=2. expect(edgeState(board, { x: 0, y: 4 }, "S")).toBe("wall"); expect(edgeState(board, { x: 1, y: 4 }, "S")).toBe("wall"); expect(edgeState(board, { x: 2, y: 4 }, "S")).toBe("open"); expect(edgeState(board, { x: 3, y: 4 }, "S")).toBe("wall"); expect(edgeState(board, { x: 4, y: 4 }, "S")).toBe("wall"); const step = stepTarget(board, { x: 2, y: 4 }, "S"); expect(step).toEqual({ kind: "step", to: { x: 2, y: 5 } }); }); it("wraps the vertical openings top-to-bottom by default", () => { const board = assembleBoard(twoSector); const up = stepTarget(board, { x: 2, y: 0 }, "N"); expect(up).toEqual({ kind: "warp", to: { x: 2, y: 9 } }); const down = stepTarget(board, { x: 2, y: 9 }, "S"); expect(down).toEqual({ kind: "warp", to: { x: 2, y: 0 } }); }); it("honors explicit crossed pairings (2-player diagram)", () => { const board = assembleBoard(twoSector, { warpPairs: [ [{ sector: 0, side: "N" }, { sector: 1, side: "S" }], [{ sector: 0, side: "W" }, { sector: 1, side: "E" }], [{ sector: 0, side: "E" }, { sector: 1, side: "W" }], ], }); // Leaving the top sector's west opening arrives at the bottom sector's east. expect(stepTarget(board, { x: 0, y: 2 }, "W")).toEqual({ kind: "warp", to: { x: 4, y: 7 } }); expect(stepTarget(board, { x: 4, y: 7 }, "E")).toEqual({ kind: "warp", to: { x: 0, y: 2 } }); }); it("blocks steps through walls and doors", () => { const board = assembleBoard([{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }]); // Layout A wall V (2,1)|(2,2): 1-indexed row 2 col 1 east = 0-indexed (0,1) E. expect(stepTarget(board, { x: 0, y: 1 }, "E")).toEqual({ kind: "blocked", by: "wall" }); // Layout A door H (3,2)-(4,2): 0-indexed (1,2) S. expect(stepTarget(board, { x: 1, y: 2 }, "S")).toEqual({ kind: "blocked", by: "door" }); }); it("rotating a sector 180 degrees preserves its wall count", () => { const flat = assembleBoard([{ boardId: "board-c", origin: { x: 0, y: 0 }, rotation: 0 }]); const rotated = assembleBoard([{ boardId: "board-c", origin: { x: 0, y: 0 }, rotation: 180 }]); const countWalls = (b: typeof flat) => Object.values(b.edges).filter((e) => e === "wall").length; const countDoors = (b: typeof flat) => Object.values(b.edges).filter((e) => e === "door").length; expect(countWalls(rotated)).toBe(countWalls(flat)); expect(countDoors(rotated)).toBe(countDoors(flat)); // Home stays centered under rotation. expect(rotated.homes[0]).toEqual({ x: 2, y: 2 }); }); it("computes line of sight blocked by walls", () => { const board = assembleBoard([{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }]); // Straight down col 0 from (0,0): wall H (3,1)|(4,1) = 0-indexed (0,2) S blocks. expect(hasLineOfSight(board, { x: 0, y: 0 }, { x: 0, y: 1 })).toBe(true); expect(hasLineOfSight(board, { x: 0, y: 0 }, { x: 0, y: 4 })).toBe(false); // Home (2,2) sees one cell up (open edge), but the wall between rows 1-2 // of the home column blocks sight to the top row. expect(hasLineOfSight(board, { x: 2, y: 2 }, { x: 2, y: 1 })).toBe(true); expect(hasLineOfSight(board, { x: 2, y: 2 }, { x: 2, y: 0 })).toBe(false); }); }); describe("player counts 2-6", () => { it("assembles a coherent board for every supported count", () => { for (const n of [2, 3, 4, 5, 6]) { const { board } = setupBoard(n, createRng(n * 7)); expect(board.homes.length).toBe(n); expect(board.placements.length).toBe(n); // Every warp starts and ends on real cells, through open edges. for (const w of board.warps) { expect(board.cells[`${w.from.cell.x},${w.from.cell.y}`]).toBe(true); expect(board.cells[`${w.to.cell.x},${w.to.cell.y}`]).toBe(true); } // No two sectors share a layout. const ids = board.placements.map((p) => p.boardId); expect(new Set(ids).size).toBe(n); } }); it("full games start at every count", () => { for (const n of [3, 5, 6]) { const ids = Array.from({ length: n }, (_, i) => `w${i}`); const { state } = createGame({ playerIds: ids, seed: 99 + n, sets: ["basic", "expansion1"] }); expect(state.players.length).toBe(n); expect(state.treasures.length).toBe(n * 2); } }); }); describe("sight and the 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("the open sides carry sight: mouths see each other through the join", () => { const board = assembleBoard(twoSector); 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("walls inside the corridor still block, and a filled mouth chokes", () => { const board = assembleBoard(twoSector); expect(sightBetween(board, { x: 2, y: 2 }, { x: 2, y: 9 })).toBe(false); expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, { "2,9": true })).toBe(false); }); }); describe("setup diagram pairings (rulebook Set-Up Diagram)", () => { function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean { if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5; if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5; if (side === "W") return c.x === origin.x && c.y >= origin.y && c.y < origin.y + 5; return c.x === origin.x + 4 && c.y >= origin.y && c.y < origin.y + 5; } function hasWarp(board: { warps: { from: { cell: { x: number; y: number } }; to: { cell: { x: number; y: number } } }[] }, a: { o: { x: number; y: number }; s: string }, b: { o: { x: number; y: number }; s: string }): boolean { return board.warps.some((w) => onEdge(w.from.cell, a.o, a.s) && onEdge(w.to.cell, b.o, b.s)); } it("3 players: the aisle-warp arc plus B, C, A letter pairs", () => { const { board } = setupBoard(3, createRng(7)); const top = { x: 5, y: 0 }, bl = { x: 0, y: 5 }, br = { x: 5, y: 5 }; expect(hasWarp(board, { o: top, s: "W" }, { o: bl, s: "N" })).toBe(true); // Aisle Warp expect(hasWarp(board, { o: top, s: "N" }, { o: br, s: "S" })).toBe(true); // B expect(hasWarp(board, { o: top, s: "E" }, { o: bl, s: "S" })).toBe(true); // C expect(hasWarp(board, { o: bl, s: "W" }, { o: br, s: "E" })).toBe(true); // A }); it("5 players: opposite tips wrap, notch corners arc around", () => { const { board } = setupBoard(5, createRng(7)); const n = { x: 5, y: 0 }, w = { x: 0, y: 5 }, e = { x: 10, y: 5 }, s = { x: 5, y: 10 }; expect(hasWarp(board, { o: n, s: "N" }, { o: s, s: "S" })).toBe(true); // A expect(hasWarp(board, { o: w, s: "W" }, { o: e, s: "E" })).toBe(true); // B expect(hasWarp(board, { o: n, s: "W" }, { o: w, s: "N" })).toBe(true); // corners expect(hasWarp(board, { o: n, s: "E" }, { o: e, s: "N" })).toBe(true); expect(hasWarp(board, { o: s, s: "W" }, { o: w, s: "S" })).toBe(true); expect(hasWarp(board, { o: s, s: "E" }, { o: e, s: "S" })).toBe(true); }); it("6 players: straight-across wrap on both axes", () => { const { board } = setupBoard(6, createRng(7)); for (const w of board.warps) { const colinear = w.from.cell.x === w.to.cell.x || w.from.cell.y === w.to.cell.y; expect(colinear).toBe(true); } }); it("relocation discards the aisle warp: only opposite edges connect after", () => { let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 }); const bent = (warps: typeof state.board.warps) => warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y); expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists const me = activePlayer(state); const rel = giveCard(state, me.id, "relocate-sector"); // Slide the top sector from (5,0) across to (0,0): an L becomes a block. const r = applyCommand(state, me.id, { type: "cast", instanceId: rel.instanceId, target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } }, }); if (!r.ok) throw new Error(r.error); expect(bent(r.state.board.warps).length).toBe(0); // "only opposite board edges connect" }); }); describe("the aisle warp treats its corner as adjacent — sight included", () => { it("sight passes through the AUTO WARP, diagonals and all", () => { const { board } = setupBoard(3, createRng(7)); const aisle = board.warps.find( (w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y, )!; expect(aisle).toBeDefined(); // From the aisle mouth, squares beyond the corner are visible — including // at least one OFF the straight axis (the adjacency promise). const mouth = aisle.from.cell; const axisVertical = aisle.from.side === "N" || aisle.from.side === "S"; let seen = 0, offAxis = 0; for (const key of Object.keys(board.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; if (hasLineOfSight(board, mouth, { x, y })) continue; if (sightBetween(board, mouth, { x, y })) { seen++; if (axisVertical ? x !== mouth.x : y !== mouth.y) offAxis++; } } expect(seen).toBeGreaterThan(0); expect(offAxis).toBeGreaterThan(0); }); }); describe("bricking over a warp mouth", () => { it("create wall seals a lettered opening: no passage, no sight", () => { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 }); // round 1 passes for (let i = 0; i < 2; i++) { const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); state = r.state; } const w = state.board.warps[0]!; const caster = activePlayer(state); caster.position = { ...w.from.cell }; caster.hand[0] = { instanceId: "cw#1", cardId: "create-wall" }; const r = applyCommand(state, caster.id, { type: "cast", instanceId: "cw#1", target: { kind: "edge", cell: w.from.cell, side: w.from.side }, }); if (!r.ok) throw new Error(r.error); state = r.state; // Passage: stepping into the warp is now a wall. const step = stepTarget(boardView(state), w.from.cell, w.from.side); expect(step.kind).toBe("blocked"); // Sight: the far mouth no longer carries warp sight. expect(gameLos(state, w.from.cell, w.to.cell)).toBe(false); // One tunnel: the FAR mouth is walled too, and unwalling it reopens both. const farStep = stepTarget(boardView(state), w.to.cell, w.to.side); expect(farStep.kind).toBe("blocked"); const caster2 = activePlayer(state); caster2.position = { ...w.to.cell }; caster2.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" }; const r2 = applyCommand(state, caster2.id, { type: "cast", instanceId: "dw#1", target: { kind: "edge", cell: w.to.cell, side: w.to.side }, }); if (!r2.ok) throw new Error(r2.error); expect(stepTarget(boardView(r2.state), w.from.cell, w.from.side).kind).toBe("warp"); }); });