"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. hasWarpLineOfSight models it: the line must run straight down the corridor, out one mouth, and in through the paired mouth, with both corridor legs clear and neither mouth filled solid. The new sightBetween (direct or through-a-warp) is now the game's LOS check everywhere — spells, ambush triggers, Around The Corner, the visionstone sweep, and the targeting dim on the client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
7.6 KiB
TypeScript
176 lines
7.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
assembleBoard,
|
|
edgeState,
|
|
hasLineOfSight,
|
|
sightBetween,
|
|
layoutIds,
|
|
stepTarget,
|
|
type SectorPlacement,
|
|
} from "../src/board";
|
|
import { buildDeck } from "../src/cards";
|
|
|
|
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", async () => {
|
|
const { setupBoard } = await import("../src/setups");
|
|
const { createRng } = await import("../src/rng");
|
|
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", async () => {
|
|
const { createGame } = await import("../src/game");
|
|
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("line of sight through 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("sees straight down the corridor, out one mouth and in the other", () => {
|
|
const board = assembleBoard(twoSector);
|
|
// (2,0) N wraps to (2,9): the mouths see each other through the join.
|
|
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("demands a straight corridor: off-axis viewers see nothing", () => {
|
|
const board = assembleBoard(twoSector);
|
|
expect(sightBetween(board, { x: 1, y: 0 }, { x: 2, y: 9 })).toBe(false);
|
|
expect(sightBetween(board, { x: 3, y: 1 }, { x: 2, y: 9 })).toBe(false);
|
|
});
|
|
|
|
it("walls inside either corridor still block the warp sight", () => {
|
|
const board = assembleBoard(twoSector);
|
|
// Layout A: the wall between (2,0) and (2,1)'s row (home column sight is
|
|
// cut above row 2 — pinned earlier) blocks a deeper viewer's warp sight.
|
|
const deepViewer = { x: 2, y: 2 };
|
|
expect(hasLineOfSight(board, deepViewer, { x: 2, y: 0 })).toBe(false);
|
|
expect(sightBetween(board, deepViewer, { x: 2, y: 9 })).toBe(false);
|
|
});
|
|
|
|
it("a filled mouth chokes the tunnel", () => {
|
|
const board = assembleBoard(twoSector);
|
|
const stone = { "2,9": true as const };
|
|
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false);
|
|
});
|
|
});
|