Rules rev 24: a waterwall wave that finds a victim dist cells from its source has only range-dist spaces of push left — a range-2 wave throws its adjacent victim two spaces but a victim at its far edge only one, and spent force never converts into crush damage. Applies to WATERWALL and both STONE TO WATER waves; older revisions keep the flat full-range wash so stored games replay unchanged. Automatons now use DESTROY WALL on the march: two BFS distance maps (from the bot, from its objectives) price every visible wall by the shortcut its removal opens; the bot blasts when it saves 4+ steps of walking — or when no road exists at all — never standing beside the blast unless trapped and healthy. The card also leaves the shed pile: discardValue 2 -> 6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
|
|
import { cellKey, edgeKey, SIDES, stepTarget, type Cell } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
|
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
|
|
|
|
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 },
|
|
});
|
|
state = must(state, me.id, { type: "move", direction: spot.side });
|
|
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(falls + jumps + teeters).toBe(12);
|
|
expect(falls).toBeGreaterThan(0);
|
|
});
|
|
|
|
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("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.
|
|
});
|
|
});
|
|
|
|
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 (rules rev 24)", () => {
|
|
/** 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(deckRev: number, behind: "open" | "wall") {
|
|
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
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(24, "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(24, "wall");
|
|
expect(cellKey(me.position)).toBe(cellKey(B));
|
|
expect(me.life).toBe(14);
|
|
});
|
|
|
|
it("older revisions keep the flat full-range wash for replay fidelity", () => {
|
|
const { me, Bnn } = rig(23, "open");
|
|
expect(cellKey(me.position)).toBe(cellKey(Bnn));
|
|
expect(me.life).toBe(15);
|
|
});
|
|
});
|