Files
wizwar6e/packages/engine/test/expansion-terrain.test.ts
T
Eric WagonerandClaude Fable 5 4a91d56d24 Credibility pass: the collapse loses its scaffolding
Blind reviews over the rev-37..40 features and the gate collapse. The
scripted surgery's scars come out: bare brace blocks unwrapped (with one
hiding an unreachable scrambleHands), the amputated comment healed, the
dead events parameter unthreaded from the illusion-sight chain, the
laziness probe in perceivedBoard collapsed to the rule it always produced,
GameView.deckRev retired with its last reader, the ward's arming stub
told honestly, and every comment or test title still citing the retired
revision numbering reworded to the rule it guards. Rim mechanics unify on
rimWarpMouth and breachRim; the test suite gains plainRimWall and
pushSustained and loses its loop-shaped scars and rev-named helpers.
No behavior changes; 255 tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 13:04:01 -04:00

381 lines
17 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);
});
});