Files
wizwar6e/packages/engine/test/expansion-terrain.test.ts
Eric WagonerandClaude Fable 5 eaa672236b The boobytrap keeps its secret
The public boobytrapPlaced event and every subsequent view shipped
the four token cells in casting order — with the real trap always
first, a tell readable by any client. Tokens are now stored and
broadcast in canonical cell order while the truth lives only in
realKey (and the caster's private event). Deterministic sort, no rng
touched, replays unaffected; a test casts in reverse order and checks
that position whispers nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 00:21:02 -04:00

458 lines
20 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
import { cellKey, edgeKey, 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 } 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.
});
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);
}
});
});