Files
wizwar6e/packages/engine/test/expansion-terrain.test.ts
T
Eric WagonerandClaude Fable 5 695307daa8 Credibility pass: one voice, no scars
A three-reviewer sweep for tells of piecemeal machine generation,
every finding verified against the code before touching it. No
behavior changes; the full suite passes unchanged (plus two
strengthened pins).

Engine: removed four void-silenced fossils (a parseEdgeKey call
voided where it stood, stoneEffect's ignored cardId parameter, the
actualTarget remnant in doCast, a voided loop variable in shadow
upkeep); fixed the initialize-then-overwrite narration in
spawnCreature; replaced a filter(() => false) no-op; waterwall now
rides waveFromEdge instead of carrying its own verbatim copy (and the
single-caller washBack wrapper went with it); blind wall-bumps and
LOS blockers each collapsed to one implementation; the wand-id list
and the "permanent" duration sentinel became named constants; the
ambush number local no longer shadows the imported numberValue
function; assorted reviewer-aimed phrasings rewritten as the
constraints they guard.

Server/deploy: the protocol header now documents all eleven message
types; dropped an eslint pragma with no eslint, a test script with no
tests, and an rsync exclude anchored at a path that never existed
(the real data/ dir now excluded); the Caddy vhost has one source of
truth; stale "pending DNS" note removed — the record resolves.

Web: ~90 lines of CSS swallowed verbatim into a mobile media query
deduplicated; the reduced-motion guard on the board now actually
stops the marked-cell pulse; one shared color module replaces two
drifted palettes; an orphaned doc comment rejoined its function.

Tests: the ten-times-pasted helper block became test/helpers.ts;
wave-numbered files renamed for the behaviors they pin; deliberation
comments and void-ed corpses of unwritten assertions deleted; silent
seed-dependent early-returns now fail loudly; one assertion that
compared a value to itself now pins the home-translation it meant to;
the stored-log single-number command form gained the explicit
compatibility test it deserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:56:55 -04:00

164 lines
7.5 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.
});
});