Files
wizwar6e/packages/engine/test/terrain-objects-control.test.ts
Eric WagonerandClaude Fable 5 92613d7386 Ops hardening plus two rules gaps close (rev 13)
Operational: auto-reboot for kernel patches (09:30 UTC), Caddy access
logs at /var/lib/caddy (self-rotating; the sandbox denies /var/log),
logrotate for the backup log, a restore drill proving a Spaces snapshot
boots 56/56 rooms clean, the about page's plain sentence on permanent
recording, and /wizwar-pulse to read it all weekly.

Rules: safes now smash — attacks aimed at the box accumulate on it and
the fifteenth point bursts it (widening, ungated). And SLOW DEATH's
bites pause for a victim holding an ABSORB — the card named by the FAQ,
not Absorb Spell — each soaking exactly one point, rev-gated at 13 with
the instant-bite legacy pinned. Bots weigh the soak against their life.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-09-01 10:35:52 -04:00

356 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers";
/** An empty visible cell adjacent to the player (not home, no treasure). */
describe("terrain", () => {
it("fill square with stone blocks movement and line of sight", () => {
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 },
});
expect(applyCommand(state, me.id, { type: "move", direction: spot.side }).ok).toBe(false);
// LOS straight through the stone is blocked.
const beyond = neighbor(spot.cell, spot.side);
if (boardView(state).cells[cellKey(beyond)]) {
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
}
});
it("thornbush entry costs a life point, the turn, and the next turn", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const tb = giveCard(state, me.id, "thornbush");
state = must(state, me.id, {
type: "cast", instanceId: tb.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)!;
expect(p.life).toBe(14);
expect(p.lostTurns).toBe(1);
expect(state.turn.actionsEnded).toBe(true);
});
it("wizards in a thornbush cannot attack or be attacked", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
// Test surgery: plant a bush and stand the defender in it.
state.squareContents[cellKey(d.position)] = { kind: "thornbush", damage: 0, createdBy: attacker };
const fb = giveCard(state, attacker, "fireball");
const refused = applyCommand(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
expect(refused.ok).toBe(false);
});
it("wall of fire burns crossers and expires with its duration", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const key = edgeKey(me.position, spot.side);
const wof = giveCard(state, me.id, "wall-of-fire");
giveCard(state, me.id, "number-2", "N", 1);
state = must(state, me.id, {
type: "cast", instanceId: wof.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "edge", cell: me.position, side: spot.side },
});
expect(boardView(state).edges[key]).toBe("firewall");
// Walking through it hurts.
state = must(state, me.id, { type: "move", direction: spot.side });
expect(state.players.find((p) => p.id === me.id)!.life).toBe(11);
// Duration 2: expires at the start of the caster's second following turn.
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(boardView(state).edges[key]).toBe("firewall"); // 1 turn left
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(boardView(state).edges[key]).toBeUndefined();
});
it("dispel creation removes a created wall", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const key = edgeKey(me.position, spot.side);
const cw = giveCard(state, me.id, "create-wall");
state = must(state, me.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: me.position, side: spot.side },
});
expect(boardView(state).edges[key]).toBe("wall");
const dc = giveCard(state, me.id, "dispel-creation");
state = must(state, me.id, {
type: "cast", instanceId: dc.instanceId,
target: { kind: "edge", cell: me.position, side: spot.side },
});
expect(boardView(state).edges[key]).toBeUndefined();
// But a printed (original) wall cannot be dispelled.
const dc2 = giveCard(state, me.id, "dispel-creation", "T2");
const view = boardView(state);
const wallEntry = Object.entries(view.edges).find(([, s]) => s === "wall")!;
const [kind, coords] = wallEntry[0].split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: dc2.instanceId,
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
});
expect(refused.ok).toBe(false);
});
});
describe("safes and lock cards", () => {
function safeRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const raider = activePlayer(state);
const owner = state.players.find((p) => p.id !== raider.id)!;
const chest = state.treasures.find((t) => t.owner === owner.id && t.position)!;
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: owner.id };
raider.position = { ...chest.position! };
return { state, raider: raider.id, chest };
}
it("a pick lock aimed at the safe's square opens it until turn's end", () => {
let { state, raider, chest } = safeRig();
expect(applyCommand(state, raider, { type: "pickUpTreasure" }).ok).toBe(false);
const pl = giveCard(state, raider, "pick-lock", "PL", 0);
const here = state.players.find((p) => p.id === raider)!.position;
state = must(state, raider, {
type: "cast", instanceId: pl.instanceId, target: { kind: "cell", cell: { ...here } },
});
expect(state.openSafes).toContain(cellKey(here));
state = must(state, raider, { type: "pickUpTreasure" });
expect(state.treasures.find((t) => t.id === chest.id)!.carriedBy).toBe(raider);
state = must(state, raider, { type: "endTurn", draw: 0 });
expect(state.openSafes).toEqual([]);
});
it("fifteen points of battering bursts the safe open", () => {
let { state, raider, chest } = safeRig();
const box = state.players.find((p) => p.id === raider)!.position;
// Two 5-point fireballs dent it; the third bursts it.
for (let i = 0; i < 3; i++) {
const fb = giveCard(state, raider, "fireball", `F${i}`, 0);
state = must(state, raider, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: { ...box } },
});
if (i < 2) {
expect(state.squareContents[cellKey(box)]?.kind).toBe("safe");
state = must(state, raider, { type: "endTurn", draw: 0 });
const other = state.players.find((p) => p.id !== raider)!;
state = must(state, other.id, { type: "endTurn", draw: 0 });
}
}
expect(state.squareContents[cellKey(box)]).toBeUndefined();
state = must(state, raider, { type: "pickUpTreasure" });
expect(state.treasures.find((t) => t.id === chest.id)!.carriedBy).toBe(raider);
});
it("the key must be at or beside the safe", () => {
let { state, raider } = safeRig();
const p = state.players.find((q) => q.id === raider)!;
const box = { ...p.position };
p.position = { x: (box.x + 3) % 10, y: (box.y + 3) % 10 };
const pl = giveCard(state, raider, "pick-lock", "PL", 0);
const r = applyCommand(state, raider, {
type: "cast", instanceId: pl.instanceId, target: { kind: "cell", cell: box },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/beside the safe/);
});
});
describe("objects", () => {
it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const dagger = giveCard(state, attacker, "dagger");
giveCard(state, defender, "full-shield", "FS", 0);
state = must(state, attacker, {
type: "cast", instanceId: dagger.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(12); // full shield "does not stop any physical attack"
const floor = state.groundObjects[cellKey(d.position)] ?? [];
expect(floor.some((c) => c.cardId === "dagger")).toBe(true);
// Anyone may pick it up — and doing so ends the turn's actions.
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, { type: "pickUpObject", instanceId: dagger.instanceId });
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
expect(state.turn.actionsEnded).toBe(true);
});
it("blunt halves a thrown rock's physical damage", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const rock = giveCard(state, attacker, "large-rock");
giveCard(state, defender, "blunt", "B", 0);
state = must(state, attacker, {
type: "cast", instanceId: rock.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "blunt#B" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // ceil(2/2)=1
});
it("drop object forces a named object to the floor; drag pulls a treasure home", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
giveCard(state, defender, "dagger", "D2", 0);
const dobj = giveCard(state, attacker, "drop-object");
state = castAt(state, attacker, defender, dobj, { params: { cardId: "dagger" } });
const dPos = state.players.find((p) => p.id === defender)!.position;
expect((state.groundObjects[cellKey(dPos)] ?? []).some((c) => c.cardId === "dagger")).toBe(true);
// Drag an enemy treasure across open floor toward the caster.
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, { type: "endTurn", draw: 0 });
const enemyTreasure = state.treasures.find((t) => t.owner === defender && t.position)!;
const me2 = state.players.find((p) => p.id === attacker)!;
// Stand adjacent-ish to the treasure with clear LOS: same square works.
me2.position = { ...enemyTreasure.position! };
const drag = giveCard(state, attacker, "drag");
state = must(state, attacker, {
type: "cast", instanceId: drag.instanceId,
target: { kind: "cell", cell: enemyTreasure.position! },
});
const t = state.treasures.find((tr) => tr.id === enemyTreasure.id)!;
expect(cellKey(t.position!)).toBe(cellKey(state.players.find((p) => p.id === attacker)!.position));
});
});
describe("control effects", () => {
it("lock in place stops moving and being moved", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lip = giveCard(state, attacker, "lock-in-place");
giveCard(state, attacker, "number-3", "N", 1);
state = castAt(state, attacker, defender, lip, { numberInstanceIds: ["number-3#N"] });
expect(sustainedOn(state, defender, "lock-in-place").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
state = must(state, defender, { type: "endTurn", draw: 0 });
// Teleport Opponent fizzles against a locked target.
const tpo = giveCard(state, attacker, "teleport-opponent");
const before = state.players.find((p) => p.id === defender)!.position;
const anywhere = state.board.homes.find((h) => cellKey(h) !== cellKey(before))!;
state = castAt(state, attacker, defender, tpo, { params: { cell: anywhere } });
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(before));
});
it("buddy prevents attacks until the caster breaks the pact", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const buddy = giveCard(state, attacker, "buddy");
state = must(state, attacker, {
type: "cast", instanceId: buddy.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, attacker, { type: "endTurn", draw: 0 });
// The defender cannot bring themselves to attack the caster.
const fb = giveCard(state, defender, "fireball", "F", 0);
const refused = applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
});
expect(refused.ok).toBe(false);
state = must(state, defender, { type: "endTurn", draw: 0 });
// The caster attacks first: the pact breaks; next turn the defender may.
const fb2 = giveCard(state, attacker, "fireball", "F2", 0);
state = castAt(state, attacker, defender, fb2);
expect(state.sustained.some((s) => s.cardId === "buddy")).toBe(false);
});
it("mist body passes through walls but cannot attack or be attacked", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const mist = giveCard(state, defender, "mist-body", "M", 0);
giveCard(state, defender, "number-3", "N", 1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, {
type: "cast", instanceId: mist.instanceId, numberInstanceIds: ["number-3#N"],
});
// Mist passes through locked DOORS but NOT the maze's stone walls.
{
const view = boardView(state);
const d = state.players.find((p) => p.id === defender)!;
const doorEntry = Object.entries(view.edges).find(([, st]) => st === "door")!;
const [kind, coords] = doorEntry[0].split(":") as [string, string];
const [dx, dy] = coords.split(",").map(Number) as [number, number];
d.position = { x: dx, y: dy };
state = must(state, defender, { type: "move", direction: kind === "V" ? "E" : "S" });
for (const side of SIDES) {
const k = edgeKey(state.players.find((p) => p.id === defender)!.position, side);
const view2 = boardView(state);
if (view2.edges[k] === "wall" &&
view2.cells[cellKey(neighbor(state.players.find((p) => p.id === defender)!.position, side))]) {
expect(applyCommand(state, defender, { type: "move", direction: side }).ok).toBe(false);
break;
}
}
}
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
const dd = state.players.find((p) => p.id === defender)!;
state.players.find((p) => p.id === attacker)!.position = { ...dd.position };
const refused = applyCommand(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
expect(refused.ok).toBe(false);
});
it("reuse spell retrieves the last spell you cast", () => {
let { state } = newGame();
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
const cw = giveCard(state, me.id, "create-wall");
state = must(state, me.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: me.position, side: spot.side },
});
const ru = giveCard(state, me.id, "reuse-spell");
state = must(state, me.id, { type: "cast", instanceId: ru.instanceId });
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true);
});
});
describe("picking one of two treasures", () => {
it("a named treasure is the one taken", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const p = activePlayer(state);
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
t1!.position = { ...p.position }; t1!.carriedBy = null;
t2!.position = { ...p.position }; t2!.carriedBy = null;
const r = applyCommand(state, p.id, { type: "pickUpTreasure", treasureId: t2!.id });
if (!r.ok) throw new Error(r.error);
expect(r.state.players.find((q) => q.id === p.id)!.carriedTreasureId).toBe(t2!.id);
});
});