Scoped to everything since the last pass (695307d). The residue of
fast iteration, removed: a reduced-motion media query that had
swallowed a full copy of the .faq-seal rules; doc comments orphaned
from their functions by inserted methods; the FAQ scrape's seams
(section headings run into ruling bodies under the wrong topics, a
next-page heading shipped as a ruling, an amputated "h", and rulings
filed under alphabetically-nearest strangers — Large Rock/Dagger now
lives on those cards; Book of Spells and Torquemada describe no card
in this set and are gone); the write-only aisle flag left behind by
the reverted rev 7; a linter-silenced dead destructure; a duplicated
median lookup; dead casts; and the fallback path that ignored the
apprentice's one-card draw.
Tests now typecheck (tsconfig includes test/), which surfaced the
missing type imports and a drifted creature literal hiding under an
as-cast. Also: a no-op self-assignment, mid-file imports hoisted, a
dynamic-import habit made static, a hedge comment replaced with a
loud setup failure, the fallback-retries-itself dead rung removed
from both bot drivers, and the twin peek scrims merged.
Deliberately kept: the TIERS lookup guard (ledger JSON is untrusted),
the wand-cost stanzas (they differ on the power-attack trade — a
rules question, not a dedup), and rollDie's coexistence with rollD4
(migrating changes visible logs; future work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
11 KiB
TypeScript
243 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";
|
|
import { activePlayer, applyCommand, createGame } 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);
|
|
});
|
|
});
|