A THORNBUSH or ROSEBUSH "will be destroyed" by five points of damage, and the FAQ lets only fire hurt a KILLER OOZE; the table refused every attack aimed at one. An attack tapped onto a bush or ooze now lands on it, the damage accumulating across attackers and turns, and the fifth point clears the square. A WIZARDBLADE is swung from the square beside it, as its text asks of a target that fills a square. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
773 lines
36 KiB
TypeScript
773 lines
36 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, 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/);
|
|
});
|
|
});
|