moves like a giant, and the boards are diagram-verified TELEPORT as a counteraction (official FAQ: "the attack has no chance of hitting you"): the defender names an escape square within four spaces, the escape resolves before anything lands, and an ANTI-ANTI pins their boots to the floor. Additive — no revision gate needed. FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime sticks in the gel (leaving the discard pile), springs once at whoever is inside or next enters, and counteractions against the trapped blast cannot touch its caster — reflections vanish into the ooze. A five-point waterbolt washes the slime and its cargo away, and the waterwall waves clear slime from their path. BIG MAN, under rules rev 5, finally moves like the card says: he pushes players and monsters down the corridor ahead of him (stuck or unpushable occupants block his advance), steps over a pit, tacks, or killer ooze for two movement points without ever entering the square (click two cells beyond the hazard), and monsters may not enter his square. All gated so stored games replay under their own rules. The boards were never wrong — the rulebook's Set-Up Diagram photo confirms every pairing the code already had: 2p crossed, 3p stair with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four corner arcs. The stale TODOs are gone, replaced by tests pinning each letter pair, and relocation's "only opposite board edges connect" (which discards the aisle warp) is pinned too. Wall of Fire vs Waterbolt turned out to be implemented and tested all along — its TODO comment was the only thing wrong. Also: hotseat saves now request durable storage (iOS evicts unprotected origins under pressure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
239 lines
11 KiB
TypeScript
239 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("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);
|
|
});
|
|
});
|
|
|
|
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"
|
|
});
|
|
});
|