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>
246 lines
11 KiB
TypeScript
246 lines
11 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("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);
|
|
});
|
|
});
|
|
|
|
import { createRng } from "../src/rng";
|
|
import { setupBoard as setupBoardForPins } from "../src/setups";
|
|
|
|
describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
|
const setupBoard = setupBoardForPins;
|
|
|
|
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", async () => {
|
|
const { createGame, applyCommand, activePlayer } = await import("../src/game");
|
|
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);
|
|
me.hand[0] = { instanceId: "rel#1", cardId: "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#1",
|
|
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 } = setupBoardForPins(3, createRng(7));
|
|
const aisle = board.warps.find((w) => w.aisle)!;
|
|
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);
|
|
});
|
|
});
|