A teleporter ignores walls, and the outer edge is no more than a wall to it: a line leaving the maze at any square's edge re-enters at the opposite edge on the same row or column, one space on. The lettered openings connect as they always did. Older games crossed the edge only at the openings, and replay so; U3U2, dealt at rev 21 with no teleport yet cast, was re-stamped to 22 at its table's request. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
824 lines
39 KiB
TypeScript
824 lines
39 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, wallIgnoringDistance, type GameState } from "../src/game";
|
|
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
|
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall, toRound2 } from "./helpers";
|
|
|
|
/** The sides a walker may leave a pit by, having entered it heading `entry`. */
|
|
function rimExits(state: GameState, pit: Cell, entry: Side): Side[] {
|
|
const view = boardView(state);
|
|
return SIDES.filter((d) => {
|
|
if (d === opposite(entry)) return false;
|
|
const b = neighbor(pit, d);
|
|
return !!view.cells[cellKey(b)] && (view.edges[edgeKey(pit, d)] ?? "open") === "open" &&
|
|
state.squareContents[cellKey(b)]?.kind !== "stone";
|
|
});
|
|
}
|
|
|
|
/** Two pit sites on a board: a fork (several ways off) and a corridor (one
|
|
* way off, straight ahead), each with the square a walker enters from. */
|
|
function pitSpots(state: GameState) {
|
|
const view = boardView(state);
|
|
let fork: { from: Cell; pit: Cell; side: Side; exits: Side[] } | null = null;
|
|
let corridor: { from: Cell; pit: Cell; side: Side; beyond: Cell } | null = null;
|
|
for (const k of Object.keys(view.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view, { x, y }, side);
|
|
if (t.kind !== "step") continue;
|
|
if (state.squareContents[k] || state.squareContents[cellKey(t.to)]) continue;
|
|
if (view.homes.some((h) => cellKey(h) === k || cellKey(h) === cellKey(t.to))) continue;
|
|
const exits = rimExits(state, t.to, side);
|
|
if (!fork && exits.length >= 2) fork = { from: { x, y }, pit: t.to, side, exits };
|
|
if (!corridor && exits.length === 1 && exits[0] === side) corridor = { from: { x, y }, pit: t.to, side, beyond: neighbor(t.to, side) };
|
|
if (fork && corridor) return { fork, corridor };
|
|
}
|
|
}
|
|
throw new Error("board lacks a fork or a corridor pit site");
|
|
}
|
|
|
|
|
|
describe("expansion terrain", () => {
|
|
it("killer ooze burns on entry and can drop you on your face", () => {
|
|
// Across seeds we should see both slips and clean crossings.
|
|
let slips = 0, crossings = 0;
|
|
for (let seed = 1; seed <= 10; seed++) {
|
|
let { state } = newGame(seed);
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const oz = giveCard(state, me.id, "killer-ooze");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: oz.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
state = must(state, me.id, { type: "move", direction: spot.side });
|
|
const p = state.players.find((p) => p.id === me.id)!;
|
|
if (p.fallenInOoze) { slips++; expect(p.life).toBe(12); } // 1 + 2
|
|
else { crossings++; expect(p.life).toBe(14); }
|
|
}
|
|
expect(slips + crossings).toBe(10);
|
|
expect(slips).toBeGreaterThan(0);
|
|
expect(crossings).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("a pit is jumped on 2-4 and fallen into on a 1", () => {
|
|
let falls = 0, jumps = 0, teeters = 0;
|
|
for (let seed = 1; seed <= 12; seed++) {
|
|
let { state } = newGame(seed);
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const pit = giveCard(state, me.id, "create-pit");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
// Rev 14: the rim's exits are the walker's to name; take the first.
|
|
const exits = rimExits(state, spot.cell, spot.side);
|
|
if (exits.length === 0) continue;
|
|
state = must(state, me.id, { type: "move", direction: spot.side, exit: exits[0] });
|
|
const p = state.players.find((p) => p.id === me.id)!;
|
|
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
|
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
|
else jumps++;
|
|
}
|
|
expect(teeters).toBe(0);
|
|
expect(falls).toBeGreaterThan(0);
|
|
expect(jumps).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("the rim leads off a pit: one way is taken, several must be named, none cannot be entered", () => {
|
|
const found = pitSpots(newGame(42).state);
|
|
// Several ways off: a bare step is refused with the choices; a named exit lands there or falls in.
|
|
{
|
|
let { state } = newGame(42);
|
|
const me = activePlayer(state);
|
|
me.position = { ...found.fork.from };
|
|
state.squareContents[cellKey(found.fork.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
|
const bare = applyCommand(state, me.id, { type: "move", direction: found.fork.side });
|
|
expect(bare.ok).toBe(false);
|
|
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
|
|
const chosen = found.fork.exits[1]!;
|
|
const named = applyCommand(state, me.id, { type: "move", direction: found.fork.side, exit: chosen });
|
|
expect(named.ok).toBe(true);
|
|
if (named.ok) {
|
|
const p = named.state.players.find((p) => p.id === me.id)!;
|
|
const landing = neighbor(found.fork.pit, chosen);
|
|
expect(p.inPit || cellKey(p.position) === cellKey(landing)).toBe(true);
|
|
}
|
|
}
|
|
// No way off: refused before any die is rolled (rev 14); bounced and charged in an older game.
|
|
for (const deckRev of [14, 13]) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
const me = activePlayer(state);
|
|
me.position = { ...found.corridor.from };
|
|
state.squareContents[cellKey(found.corridor.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
|
state.squareContents[cellKey(found.corridor.beyond)] = { kind: "stone", damage: 0, createdBy: me.id };
|
|
const r = applyCommand(state, me.id, { type: "move", direction: found.corridor.side });
|
|
if (deckRev >= 14) {
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/cannot be crossed/);
|
|
} else {
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
const p = r.state.players.find((p) => p.id === me.id)!;
|
|
if (!p.inPit) {
|
|
expect(cellKey(p.position)).toBe(cellKey(found.corridor.from));
|
|
expect(r.events.some((e) => e.type === "moveBumped")).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("dust cloud blinds anyone standing inside it", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const dc = giveCard(state, me.id, "dust-cloud");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
// LOS through the cloud is blocked.
|
|
const view = boardView(state);
|
|
const beyond = { x: spot.cell.x + (spot.cell.x - me.position.x), y: spot.cell.y + (spot.cell.y - me.position.y) };
|
|
if (view.cells[cellKey(beyond)]) {
|
|
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("a wizard in a dust cloud casts no LOS spell out, and none reaches them (rev 19)", () => {
|
|
for (const deckRev of [19, 18]) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
state.squareContents[cellKey(spot.cell)] = { kind: "dust", damage: 0, createdBy: me.id };
|
|
me.position = { ...spot.cell };
|
|
// An empty square beside the cloud, in plain sight but for the dust.
|
|
const outside = emptyNeighborCell(state, spot.cell).cell;
|
|
const blind = deckRev >= 19;
|
|
expect(gameLos(state, me.position, outside, me.id)).toBe(!blind);
|
|
expect(gameLos(state, outside, me.position)).toBe(!blind);
|
|
expect(gameLos(state, me.position, me.position, me.id)).toBe(true);
|
|
const sighted = sightedCellsFor(viewFor(state, me.id));
|
|
expect(sighted.has(cellKey(outside))).toBe(!blind);
|
|
expect(sighted.has(cellKey(me.position))).toBe(true);
|
|
// FILL SQUARE WITH STONE from inside the cloud: refused when blind.
|
|
const stone = giveCard(state, me.id, "fill-square-with-stone");
|
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: stone.instanceId, target: { kind: "cell", cell: outside } });
|
|
expect(r.ok).toBe(!blind);
|
|
}
|
|
});
|
|
|
|
it("glue pins a treasure to the floor until it wears off", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const treasure = state.treasures.find((t) => t.position && t.owner !== me.id)!;
|
|
me.position = { ...treasure.position! };
|
|
const gl = giveCard(state, me.id, "glue");
|
|
giveCard(state, me.id, "number-2", "N", 1);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: gl.instanceId, numberInstanceIds: ["number-2#N"],
|
|
target: { kind: "cell", cell: treasure.position! },
|
|
});
|
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(false);
|
|
// 2 x 2 = 4 of the caster's turns later, the glue dries out.
|
|
for (let i = 0; i < 8; i++) {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
}
|
|
state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! };
|
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true);
|
|
});
|
|
|
|
it("a safe locks a treasure away from everyone but its creator", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
const treasure = state.treasures.find((t) => t.position && t.owner === enemy.id)!;
|
|
me.position = { ...treasure.position! };
|
|
const sf = giveCard(state, me.id, "safe");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: sf.instanceId, target: { kind: "cell", cell: treasure.position! },
|
|
});
|
|
// The enemy cannot take it...
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
|
e.position = { ...treasure.position! };
|
|
expect(applyCommand(state, enemy.id, { type: "pickUpTreasure" }).ok).toBe(false);
|
|
// ...but the creator knows the combination.
|
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
|
state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! };
|
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true);
|
|
});
|
|
|
|
it("boobytrap detonates only under its real token, never under the caster", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
// Four distinct empty-ish cells: use home-adjacent floor cells of the board.
|
|
const view = boardView(state);
|
|
const open: Cell[] = [];
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
const c = { x, y };
|
|
if (state.squareContents[key]) continue;
|
|
open.push(c);
|
|
if (open.length === 4) break;
|
|
}
|
|
const bt = giveCard(state, me.id, "boobytrap");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: bt.instanceId, params: { cells: open },
|
|
});
|
|
// Caster strolls across the real token unharmed.
|
|
const caster = state.players.find((p) => p.id === me.id)!;
|
|
caster.position = { ...open[0]! };
|
|
// (position set directly — trap only triggers on a move; simulate enemy)
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
|
// Stand the enemy adjacent to the real token and step onto it.
|
|
for (const side of SIDES) {
|
|
const from = { x: open[0]!.x + (side === "E" ? -1 : side === "W" ? 1 : 0),
|
|
y: open[0]!.y + (side === "S" ? -1 : side === "N" ? 1 : 0) };
|
|
if (!view.cells[cellKey(from)]) continue;
|
|
const t = stepTarget(view, from, side);
|
|
if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) {
|
|
e.position = from;
|
|
state = must(state, enemy.id, { type: "move", direction: side });
|
|
const hurt = state.players.find((p) => p.id === enemy.id)!;
|
|
expect(hurt.life).toBeLessThanOrEqual(11);
|
|
expect(state.boobytraps.length).toBe(0);
|
|
return;
|
|
}
|
|
}
|
|
throw new Error("setup: seed 42 offers no approach to the trap");
|
|
});
|
|
|
|
it("stone to water melts a stone block into a crashing wave", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const fs = giveCard(state, me.id, "fill-square-with-stone");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
const stw = giveCard(state, me.id, "stone-to-water", "SW", 1);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
|
|
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
|
});
|
|
|
|
it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
|
|
let { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const warp = state.board.warps[0]!;
|
|
me.position = { ...warp.from.cell };
|
|
const wof = giveCard(state, me.id, "wall-of-fire", "WF", 0);
|
|
const r = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: wof.instanceId,
|
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
|
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
|
expect(boardView(state).edges[nearKey]).toBe("firewall");
|
|
expect(boardView(state).edges[farKey]).toBe("firewall");
|
|
// The corridor still runs — through flame: the crossing lands on the
|
|
// far rim and burns for 4.
|
|
state = must(state, me.id, { type: "move", direction: warp.from.side });
|
|
const after = state.players.find((p) => p.id === me.id)!;
|
|
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
|
expect(after.life).toBe(11);
|
|
});
|
|
|
|
it("waterwall takes a rim warp — the collapse washes both rims", () => {
|
|
let { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const other = state.players.find((p) => p.id !== me.id)!;
|
|
const warp = state.board.warps[0]!;
|
|
me.position = { ...warp.from.cell };
|
|
other.position = { ...warp.to.cell };
|
|
const ww = giveCard(state, me.id, "waterwall", "WW", 0);
|
|
const r = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: ww.instanceId,
|
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
// Both wizards stood in the mouths; the collapse washed both inward.
|
|
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
|
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
|
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
|
|
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
|
|
});
|
|
|
|
it("illusion wall hangs on a rim warp mouth", () => {
|
|
const { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const warp = state.board.warps[0]!;
|
|
me.position = { ...warp.from.cell };
|
|
const il = giveCard(state, me.id, "illusion-wall", "IL", 0);
|
|
const r = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: il.instanceId,
|
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("stone to water breaches the outer rim, opening a warp", () => {
|
|
let { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const rim = plainRimWall(state);
|
|
me.position = { ...rim.cell };
|
|
const warpsBefore = state.board.warps.length;
|
|
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: stw.instanceId,
|
|
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
|
});
|
|
// The far rim melted with it and the wraparound now runs.
|
|
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
|
});
|
|
|
|
it("no doors through the rim", () => {
|
|
const { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const rim = plainRimWall(state);
|
|
me.position = { ...rim.cell };
|
|
const cd = giveCard(state, me.id, "create-door", "CD", 0);
|
|
const r = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: cd.instanceId,
|
|
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("stone to water melts a door — a small entryway in a stone wall", () => {
|
|
const { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const board = boardView(state);
|
|
// Stand the caster at some door and melt it point-blank.
|
|
let doorAt: { cell: Cell; side: Side } | null = null;
|
|
outer: for (const k of Object.keys(board.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
for (const side of SIDES) {
|
|
if (board.edges[edgeKey({ x, y }, side)] === "door") {
|
|
doorAt = { cell: { x, y }, side };
|
|
break outer;
|
|
}
|
|
}
|
|
}
|
|
if (!doorAt) throw new Error("setup: no door on this board");
|
|
me.position = { ...doorAt.cell };
|
|
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
|
const r = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: stw.instanceId,
|
|
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
const key = edgeKey(doorAt.cell, doorAt.side);
|
|
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
|
expect(r.state.doorStates[key]).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("eligibility dimming mirrors the engine", () => {
|
|
it("boobytrap tokens go anywhere but solid stone, sight be damned", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state).id;
|
|
const view = viewFor(state, caster);
|
|
const lit = eligibleCellsFor(view, "boobytrap")!;
|
|
// Every board square is fair game (the fresh maze holds no solid stone),
|
|
// including squares far outside the caster's sight.
|
|
expect(lit.size).toBe(Object.keys(view.board.cells).length);
|
|
});
|
|
|
|
it("glue lights only sighted squares that hold something", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
// Drop a dagger at the caster's feet — the one guaranteed-sighted object.
|
|
const here = { ...caster.position };
|
|
state.groundObjects[cellKey(here)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
|
|
const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!;
|
|
expect(lit.has(cellKey(here))).toBe(true);
|
|
// Empty squares stay dim — glue needs something to glue down.
|
|
const view = viewFor(state, caster.id);
|
|
for (const k of lit) {
|
|
const held =
|
|
(view.groundObjects[k] ?? []).length > 0 ||
|
|
view.treasures.some((t) => t.position && cellKey(t.position) === k);
|
|
expect(held).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("dispel dimming lights creatures and demands sight, whoever made them", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
const other = state.players.find((p) => p.id !== caster.id)!;
|
|
// An enemy wraith beside the caster: created by the OTHER wizard.
|
|
state.creatures.push({
|
|
id: "w1", kind: "wraith", controllerId: other.id,
|
|
position: { x: caster.position.x, y: caster.position.y },
|
|
damage: 0, maxDamage: Infinity, movesPerTurn: 3, movementUsed: 0,
|
|
attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 1, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
const lit = eligibleCellsFor(viewFor(state, caster.id), "dispel-creation")!;
|
|
expect(lit.has(cellKey(caster.position))).toBe(true);
|
|
// Everything lit is created AND sighted.
|
|
const view = viewFor(state, caster.id);
|
|
const seen = sightedCellsFor(view);
|
|
for (const k of lit) expect(seen.has(k)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("a wave's force is spent as it travels", () => {
|
|
/** Caster at B, one cell above A; the target wall is A's south edge, so
|
|
* the range-2 wave covers A (dist 0) and B (dist 1). */
|
|
function rig(behind: "open" | "wall") {
|
|
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
const me = activePlayer(state);
|
|
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
|
|
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
|
|
state.edgeOverrides[edgeKey(A, "S")] = "wall";
|
|
state.edgeOverrides[edgeKey(A, "N")] = "open";
|
|
state.edgeOverrides[edgeKey(B, "N")] = behind;
|
|
state.edgeOverrides[edgeKey(Bn, "N")] = "open";
|
|
me.position = { ...B };
|
|
// The other wizard waits far outside the wave.
|
|
state.players.find((p) => p.id !== me.id)!.position = { x: 0, y: 9 };
|
|
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
|
const after = must(state, me.id, {
|
|
type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: A, side: "S" },
|
|
});
|
|
return { me: after.players.find((p) => p.id === me.id)!, B, Bn, Bnn };
|
|
}
|
|
|
|
it("a victim at the wave's far edge is carried one space, unhurt", () => {
|
|
const { me, Bn } = rig("open");
|
|
expect(cellKey(me.position)).toBe(cellKey(Bn));
|
|
expect(me.life).toBe(15);
|
|
});
|
|
|
|
it("only unspent force crushes: one space of push blocked is one damage", () => {
|
|
const { me, B } = rig("wall");
|
|
expect(cellKey(me.position)).toBe(cellKey(B));
|
|
expect(me.life).toBe(14);
|
|
});
|
|
|
|
});
|
|
|
|
describe("boobytrap decoys die their own deaths", () => {
|
|
it("a stepped-on blank token vanishes alone; the real one still waits", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
const view = boardView(state);
|
|
const open: Cell[] = [];
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
if (state.squareContents[key]) continue;
|
|
open.push({ x, y });
|
|
if (open.length === 4) break;
|
|
}
|
|
const bt = giveCard(state, me.id, "boobytrap");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: bt.instanceId, params: { cells: open },
|
|
});
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
// The enemy steps onto a BLANK (open[1] — the real trap is open[0]).
|
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
|
const blank = open[1]!;
|
|
for (const side of SIDES) {
|
|
const from = { x: blank.x + (side === "E" ? -1 : side === "W" ? 1 : 0),
|
|
y: blank.y + (side === "S" ? -1 : side === "N" ? 1 : 0) };
|
|
if (!view.cells[cellKey(from)]) continue;
|
|
const t = stepTarget(view, from, side);
|
|
if (t.kind === "step" && cellKey(t.to) === cellKey(blank)) {
|
|
e.position = from;
|
|
const before = e.life;
|
|
const r = applyCommand(state, enemy.id, { type: "move", direction: side });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
expect(r.events.some((ev) => ev.type === "boobytrapBlank")).toBe(true);
|
|
const after = state.players.find((p) => p.id === enemy.id)!;
|
|
expect(after.life).toBe(before);
|
|
const trap = state.boobytraps[0]!;
|
|
expect(trap.cells.length).toBe(3);
|
|
expect(trap.cells.some((c) => cellKey(c) === cellKey(blank))).toBe(false);
|
|
expect(trap.realKey).toBe(cellKey(open[0]!));
|
|
return;
|
|
}
|
|
}
|
|
throw new Error("setup: no approach to the blank token");
|
|
});
|
|
});
|
|
|
|
describe("the boobytrap keeps its secret", () => {
|
|
it("stored and broadcast cells are canonically ordered — position tells nothing", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const view = boardView(state);
|
|
const open: Cell[] = [];
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
if (state.squareContents[key]) continue;
|
|
open.push({ x, y });
|
|
if (open.length === 4) break;
|
|
}
|
|
// Cast with the REAL trap deliberately last-sorting: reverse order.
|
|
const reversed = [...open].reverse();
|
|
const bt = giveCard(state, me.id, "boobytrap");
|
|
const r = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: bt.instanceId, params: { cells: reversed },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
const trap = r.state.boobytraps[0]!;
|
|
const keys = trap.cells.map((c) => cellKey(c));
|
|
expect([...keys].sort()).toEqual(keys); // canonical order, not casting order
|
|
expect(trap.realKey).toBe(cellKey(reversed[0]!)); // the truth survives aside
|
|
const placed = r.events.find((e) => e.type === "boobytrapPlaced");
|
|
if (placed?.type === "boobytrapPlaced") {
|
|
const evKeys = placed.cells.map((c) => cellKey(c));
|
|
expect([...evKeys].sort()).toEqual(evKeys);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("a pit on the board's rim (rev 17)", () => {
|
|
/** A square on the outer rim with a warp mouth on one side, entered from
|
|
* the square opposite the mouth; its other neighbouring squares listed. */
|
|
function rimPitSite(state: GameState) {
|
|
const view = boardView(state);
|
|
for (const k of Object.keys(view.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
const pit = { x, y };
|
|
if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue;
|
|
for (const mouth of SIDES) {
|
|
if (stepTarget(view, pit, mouth).kind !== "warp") continue;
|
|
const entry = stepTarget(view, pit, opposite(mouth));
|
|
if (entry.kind !== "step" || state.squareContents[cellKey(entry.to)]) continue;
|
|
const floor = SIDES.filter((d) => d !== mouth && d !== opposite(mouth) && stepTarget(view, pit, d).kind === "step");
|
|
if (floor.length === 0) continue;
|
|
return { pit, mouth, from: entry.to, floor, far: stepTarget(view, pit, mouth) as { kind: "warp"; to: Cell } };
|
|
}
|
|
}
|
|
throw new Error("no rim pit site on this board");
|
|
}
|
|
const stoneUp = (state: GameState, pit: Cell, sides: Side[], by: string) => {
|
|
for (const d of sides) state.squareContents[cellKey(neighbor(pit, d))] = { kind: "stone", damage: 0, createdBy: by };
|
|
};
|
|
for (const deckRev of [17, 16]) {
|
|
it(`walls either side, the warp mouth is the way off (rev ${deckRev})`, () => {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
const site = rimPitSite(state);
|
|
const me = activePlayer(state);
|
|
me.position = { ...site.from };
|
|
state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
|
stoneUp(state, site.pit, site.floor, me.id);
|
|
const r = applyCommand(state, me.id, { type: "move", direction: site.mouth });
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
const p = r.state.players.find((p) => p.id === me.id)!;
|
|
if (p.inPit) return;
|
|
expect(cellKey(p.position)).toBe(cellKey(site.far.to));
|
|
expect(r.events.some((e) => e.type === "jumpedPit" && e.via === "warp")).toBe(true);
|
|
});
|
|
}
|
|
it("a square beside the mouth: rev 17 asks which, rev 16 steps to the square but may name the mouth", () => {
|
|
for (const deckRev of [17, 16]) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
const site = rimPitSite(state);
|
|
const me = activePlayer(state);
|
|
me.position = { ...site.from };
|
|
state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
|
stoneUp(state, site.pit, site.floor.slice(1), me.id);
|
|
const bare = applyCommand(state, me.id, { type: "move", direction: site.mouth });
|
|
if (deckRev >= 17) {
|
|
expect(bare.ok).toBe(false);
|
|
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
|
|
const named = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
|
|
expect(named.ok).toBe(true);
|
|
if (named.ok) {
|
|
const p = named.state.players.find((p) => p.id === me.id)!;
|
|
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
|
|
}
|
|
} else {
|
|
expect(bare.ok).toBe(true);
|
|
if (bare.ok) {
|
|
const p = bare.state.players.find((p) => p.id === me.id)!;
|
|
expect(p.inPit || cellKey(p.position) === cellKey(neighbor(site.pit, site.floor[0]!))).toBe(true);
|
|
}
|
|
// Named, the mouth is taken in any revision.
|
|
const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
|
|
expect(mouth.ok).toBe(true);
|
|
if (mouth.ok) {
|
|
const p = mouth.state.players.find((p) => p.id === me.id)!;
|
|
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("a wave names its victims before it pushes (rev 20)", () => {
|
|
/** A wall between two squares, a wizard on its near side with one open
|
|
* square behind them and a wall beyond it, and a caster's square beside. */
|
|
function site(state: GameState) {
|
|
const view = boardView(state);
|
|
for (const k of Object.keys(view.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
const a = { x, y };
|
|
if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue;
|
|
if ((view.edges[edgeKey(a, "S")] ?? "open") !== "wall" || !view.cells[cellKey(neighbor(a, "S"))]) continue;
|
|
const back = stepTarget(view, a, "N");
|
|
if (back.kind !== "step" || state.squareContents[cellKey(back.to)] || view.homes.some((h) => cellKey(h) === cellKey(back.to))) continue;
|
|
if (stepTarget(view, back.to, "N").kind !== "blocked") continue;
|
|
const beside = stepTarget(view, a, "E");
|
|
if (beside.kind !== "step" || state.squareContents[cellKey(beside.to)]) continue;
|
|
return { a, back: back.to, beside: beside.to };
|
|
}
|
|
throw new Error("no such wall on this board");
|
|
}
|
|
for (const [deckRev, crush] of [[20, 1], [19, 2]] as const) {
|
|
it(`washed one square into a wall: rev ${deckRev} costs ${crush}`, () => {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
const caster = activePlayer(state);
|
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
|
const s = site(state);
|
|
caster.position = { ...s.beside };
|
|
victim.position = { ...s.a };
|
|
const card = giveCard(state, caster.id, "stone-to-water");
|
|
const r = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId, target: { kind: "edge", cell: s.a, side: "S" } });
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
const v = r.state.players.find((p) => p.id === victim.id)!;
|
|
expect(cellKey(v.position)).toBe(cellKey(s.back));
|
|
expect(v.life).toBe(15 - crush);
|
|
expect(r.events.filter((e) => e.type === "washedBack" && e.player === victim.id).length).toBe(crush === 1 ? 1 : 2);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("spells cast at a slime wait in the gel", () => {
|
|
it("a lightning blast lodges in the slime and goes off on the next wizard in, once", () => {
|
|
let { state } = newGame(42);
|
|
state = toRound2(state);
|
|
const caster = activePlayer(state);
|
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
|
const spot = emptyNeighborCell(state, caster.position);
|
|
state.squareContents[cellKey(spot.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
|
|
const bolt = giveCard(state, caster.id, "lightning-blast");
|
|
const cast = applyCommand(state, caster.id, { type: "cast", instanceId: bolt.instanceId, target: { kind: "cell", cell: spot.cell } });
|
|
expect(cast.ok).toBe(true);
|
|
if (!cast.ok) return;
|
|
expect(cast.events.some((e) => e.type === "spellTrapped")).toBe(true);
|
|
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
|
|
// Nobody is hurt yet; the victim walks in on their own turn.
|
|
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
|
|
const v = state.players.find((p) => p.id === victim.id)!;
|
|
v.position = { ...caster.position };
|
|
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
|
|
expect(walk.ok).toBe(true);
|
|
if (!walk.ok) return;
|
|
expect(walk.events.some((e) => e.type === "slimeTrapSprung")).toBe(true);
|
|
expect(walk.state.stack?.defenderId).toBe(victim.id);
|
|
expect(walk.state.stack?.trapped).toBe(true);
|
|
const hit = must(walk.state, victim.id, { type: "pass" });
|
|
expect(hit.players.find((p) => p.id === victim.id)!.life).toBeLessThan(15);
|
|
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("attacks aimed at a bush or the ooze", () => {
|
|
function withContent(kind: "thornbush" | "rosebush" | "ooze") {
|
|
const state = toRound2(newGame().state);
|
|
const me = activePlayer(state);
|
|
const { cell } = emptyNeighborCell(state, me.position);
|
|
state.squareContents[cellKey(cell)] = { kind, damage: 0, createdBy: "bob" };
|
|
return { state, me, cell };
|
|
}
|
|
|
|
it("a FIREBALL tears a thornbush apart at five points", () => {
|
|
const { state, me, cell } = withContent("thornbush");
|
|
const fireball = giveCard(state, me.id, "fireball");
|
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDamaged", kind: "thornbush", amount: 5, total: 5 }));
|
|
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDestroyed", kind: "thornbush" }));
|
|
expect(r.state.squareContents[cellKey(cell)]).toBeUndefined();
|
|
expect(r.state.turn.attackUsed).toBe(true);
|
|
});
|
|
|
|
it("a thrown dagger scratches a rosebush, and the scratch stays", () => {
|
|
const { state, me, cell } = withContent("rosebush");
|
|
const dagger = giveCard(state, me.id, "dagger");
|
|
const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
|
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 }));
|
|
});
|
|
|
|
it("only fire hurts the ooze", () => {
|
|
const { state, me, cell } = withContent("ooze");
|
|
const dagger = giveCard(state, me.id, "dagger");
|
|
const refused = applyCommand(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
|
expect(refused.ok).toBe(false);
|
|
if (!refused.ok) expect(refused.error).toMatch(/only fire/);
|
|
const fireball = giveCard(state, me.id, "fireball");
|
|
const burned = must(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
|
expect(burned.squareContents[cellKey(cell)]).toBeUndefined();
|
|
});
|
|
|
|
it("a WIZARDBLADE is swung at a bush from the square beside it, never from afar", () => {
|
|
const { state, me, cell } = withContent("thornbush");
|
|
const blade = giveCard(state, me.id, "wizardblade");
|
|
const three = giveCard(state, me.id, "number-3", "N", 1);
|
|
const near = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell },
|
|
});
|
|
expect(near.ok).toBe(true);
|
|
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
|
|
|
|
const far = sightedCellsFor(viewFor(state, me.id));
|
|
const farKey = [...far].find((k) => {
|
|
const [x, y] = k.split(",").map(Number);
|
|
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
|
|
});
|
|
if (!farKey) return;
|
|
const [fx, fy] = farKey.split(",").map(Number);
|
|
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
|
|
const refused = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell: { x: fx!, y: fy! } },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
if (!refused.ok) expect(refused.error).toMatch(/beside/);
|
|
});
|
|
});
|
|
|
|
describe("teleport's reach wraps through the board's openings", () => {
|
|
it("offers the square beyond a warp mouth, as the engine allows it", () => {
|
|
const state = toRound2(newGame().state);
|
|
const me = activePlayer(state);
|
|
const view = boardView(state);
|
|
const warp = view.warps[0]!;
|
|
me.position = { ...warp.from.cell };
|
|
giveCard(state, me.id, "teleport");
|
|
const cells = eligibleCellsFor(viewFor(state, me.id), "teleport")!;
|
|
const beyond = cellKey(warp.to.cell);
|
|
expect(cells.has(beyond)).toBe(true);
|
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: warp.to.cell } });
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("teleport across the maze's outer edge (rev 22)", () => {
|
|
function atTheTopEdge(deckRev?: number) {
|
|
const config = { playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] as ("basic" | "expansion1")[], ...(deckRev ? { deckRev } : {}) };
|
|
const state = toRound2(createGame(config).state);
|
|
const me = activePlayer(state);
|
|
const view = boardView(state);
|
|
// A square on the top row whose north side is plain edge, not a lettered mouth.
|
|
const top = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number])
|
|
.filter(([x, y]) => y === 0 && !view.warps.some((w) => w.from.cell.x === x && w.from.cell.y === y && w.from.side === "N"))
|
|
.find(([x]) => !state.squareContents[`${x},0`])!;
|
|
me.position = { x: top[0], y: 0 };
|
|
const column = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number]).filter(([x]) => x === top[0]).map(([, y]) => y);
|
|
const bottom = { x: top[0], y: Math.max(...column) };
|
|
giveCard(state, me.id, "teleport");
|
|
return { state, me, bottom };
|
|
}
|
|
|
|
it("re-enters at the bottom of the same column, one space on", () => {
|
|
const { state, me, bottom } = atTheTopEdge();
|
|
expect(wallIgnoringDistance(boardView(state), me.position, bottom, true)).toBe(1);
|
|
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(true);
|
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) expect(r.state.players.find((p) => p.id === me.id)!.position).toEqual(bottom);
|
|
});
|
|
|
|
it("older games cross only at the lettered openings", () => {
|
|
const { state, me, bottom } = atTheTopEdge(21);
|
|
expect(wallIgnoringDistance(boardView(state), me.position, bottom)).toBeGreaterThan(4);
|
|
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(false);
|
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|