Files
wizwar6e/packages/engine/test/expansion-terrain.test.ts
T
Eric WagonerandClaude Fable 5 ac53773fe4 The dimming aid stops lying about boobytrap, glue, and safe
Three cards sat wrongly in the creation bucket of eligibleCellsFor,
so the targeting dim demanded sighted empty squares the engine never
asks for. Boobytrap tokens go anywhere on the board but solid stone —
no sight required; a trap you can see coming is a poor trap. Glue and
the safe were worse than restricted: inverted. Both seize a square
that HOLDS a loose object or treasure, exactly the squares the empty-
square rule dimmed out. Each now has its own branch mirroring the
engine's actual checks, pinned by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:31:57 -04:00

196 lines
8.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
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", async () => {
const { viewFor, eligibleCellsFor } = await import("../src/view");
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", async () => {
const { viewFor, eligibleCellsFor } = await import("../src/view");
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.
for (const k of lit) {
const view = viewFor(state, caster.id);
const held =
(view.groundObjects[k] ?? []).length > 0 ||
view.treasures.some((t) => t.position && cellKey(t.position) === k);
expect(held).toBe(true);
}
});
});