Files
wizwar6e/packages/engine/test/creatures.test.ts
T
Eric WagonerandClaude Fable 5.1 7a0a972452 A creature's blow is named on the stack: the counter is played against the fire imp's scorch, not a punch (SC59)
The caster whose creature is countered now sees "your attack, answered"
and the name of their own blow, instead of an incoming punch with the
opponent's counter card standing in the attack slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-24 21:32:34 -04:00

1047 lines
50 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame, gameLos,
} from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained, faceOff } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
const me = state.players.find((p) => p.id === playerId)!;
const spot = emptyNeighborCell(state, me.position);
const card = giveCard(state, playerId, kind, tag);
const next = must(state, playerId, {
type: "cast", instanceId: card.instanceId, target: { kind: "cell", cell: spot.cell },
});
return { state: next, at: spot.cell };
}
describe("expansion deck", () => {
it("basic + expansion1 builds the full 200-card game", () => {
// Three seats: two-player games shed LIFESAVER from the build.
const { state } = newGame(42, ["a", "b", "c"]);
const total = state.deck.length + state.discard.length +
state.players.reduce((s, p) => s + p.hand.length, 0);
expect(total).toBe(200);
});
});
describe("monsters", () => {
it("summoning uses your attack; the monster moves now but attacks next turn", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "skeleton");
state = r.state;
const skeleton = creatureAt(state, r.at)!;
expect(skeleton.kind).toBe("skeleton");
expect(state.turn.attackUsed).toBe(true);
// It can move on the creation turn...
const view = boardView(state);
for (const side of SIDES) {
if (stepTarget(view, skeleton.position, side).kind === "step") {
state = must(state, me, { type: "moveCreature", creatureId: skeleton.id, direction: side });
break;
}
}
// ...but cannot attack until next turn.
const other = state.players.find((p) => p.id !== me)!;
other.position = { ...state.creatures[0]!.position };
const refused = applyCommand(state, me, {
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: other.id,
});
expect(refused.ok).toBe(false);
// Next turn: the skeleton punches for 2.
state = must(state, me, { type: "endTurn", draw: 0 });
state = must(state, other.id, { type: "endTurn", draw: 0 });
const victim = state.players.find((p) => p.id !== me)!;
victim.position = { ...state.creatures[0]!.position };
state = must(state, me, {
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
});
// The blow opens the victim's counteraction window; they take it raw.
state = must(state, victim.id, { type: "pass" });
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
});
it("a monster will not obey the enemy and will not strike its creator", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "skeleton");
state = r.state;
const id = state.creatures[0]!.id;
const other = state.players.find((p) => p.id !== me)!.id;
state = must(state, me, { type: "endTurn", draw: 0 });
expect(applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: "N" }).ok).toBe(false);
state = must(state, other, { type: "endTurn", draw: 0 });
const creator = state.players.find((p) => p.id === me)!;
creator.position = { ...state.creatures[0]!.position };
expect(applyCommand(state, me, { type: "creatureAttack", creatureId: id, targetId: me }).ok).toBe(false);
});
it("the troll rolls a D4 for damage and regenerates at its creator's turn end", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "troll");
state = r.state;
const troll = state.creatures[0]!;
// Hurt the troll, then watch a point come back at end of turn.
const fb = giveCard(state, me, "fireball", "F", 1);
// (can't attack again this turn — adrenaline not in play — so wound it via
// test surgery instead)
void fb;
troll.damage = 3;
state = must(state, me, { type: "endTurn", draw: 0 });
expect(state.creatures[0]!.damage).toBe(2);
});
it("the wraith slips through one wall per turn and its touch steals cards", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "wraith");
state = r.state;
const wraith = state.creatures[0]!;
// Walk it through a wall if one is adjacent.
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, wraith.position, side);
if (t.kind === "blocked" && t.by === "wall") {
const dest = { x: wraith.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: wraith.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (!view.cells[cellKey(dest)]) continue;
state = must(state, me, { type: "moveCreature", creatureId: wraith.id, direction: side });
expect(state.creatures[0]!.wallPassUsed).toBe(1);
break;
}
}
// Its touch: 2 damage and a random card lost. March it onto the enemy.
state = must(state, me, { type: "endTurn", draw: 0 });
const enemy = state.players.find((p) => p.id !== me)!;
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
const w = state.creatures[0]!;
const enemy2 = state.players.find((p) => p.id !== me)!;
enemy2.position = { ...w.position };
// A touch requires the WRAITH to enter the square — step it away and back.
const view2 = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view2, w.position, side);
if (t.kind === "step") {
const back: Side = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E";
state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: side });
state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: back });
break;
}
}
state = must(state, enemy2.id, { type: "pass" });
const bitten = state.players.find((p) => p.id !== me)!;
expect(bitten.life).toBe(13);
expect(bitten.hand.length).toBe(6);
});
it("the fire imp scorches on sight and dies only to water", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "fire-imp");
state = drain(r.state);
const imp = state.creatures[0]!;
// Fireball cannot destroy it...
state = drain(must(state, me, { type: "endTurn", draw: 0 }));
const enemy = state.players.find((p) => p.id !== me)!;
// (enemy may have been scorched at turn start if in LOS — note life)
const enemyNow = state.players.find((p) => p.id !== me)!;
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
state = drain(must(state, enemy.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
}));
expect(state.creatures.length).toBe(1);
// ...but a waterbolt douses it instantly.
state = drain(must(state, enemy.id, { type: "endTurn", draw: 0 }));
state = drain(must(state, me, { type: "endTurn", draw: 0 }));
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
state = must(state, enemy.id, {
type: "cast", instanceId: wb.instanceId, target: { kind: "creature", creatureId: imp.id },
});
expect(state.creatures.length).toBe(0);
});
it("the democratic monster is moved by every player", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "democratic-monster");
state = r.state;
const id = state.creatures[0]!.id;
state = must(state, me, { type: "endTurn", draw: 0 });
const other = activePlayer(state).id;
expect(other).not.toBe(me);
const view = boardView(state);
for (const side of SIDES) {
if (stepTarget(view, state.creatures[0]!.position, side).kind === "step") {
const result = applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: side });
expect(result.ok).toBe(true);
break;
}
}
});
it("mega-monster doubles a monster's toughness; dispel un-creates it", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "skeleton");
state = r.state;
const id = state.creatures[0]!.id;
const mm = giveCard(state, me, "mega-monster", "MM", 1);
state = must(state, me, {
type: "cast", instanceId: mm.instanceId, target: { kind: "creature", creatureId: id },
});
expect(state.creatures[0]!.maxDamage).toBe(8);
// "Doubles the existing life-points OR movement rate" — the other choice.
const movesBefore = state.creatures[0]!.movesPerTurn;
const mm2 = giveCard(state, me, "mega-monster", "MM2", 3);
state = must(state, me, {
type: "cast", instanceId: mm2.instanceId, target: { kind: "creature", creatureId: id },
params: { boost: "movement" },
});
expect(state.creatures[0]!.movesPerTurn).toBe(movesBefore * 2);
expect(state.creatures[0]!.maxDamage).toBe(8);
const dc = giveCard(state, me, "dispel-creation", "DC", 2);
state = must(state, me, {
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position },
});
expect(state.creatures.length).toBe(0);
});
it("monsters vanish when their creator dies", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "skeleton");
state = r.state;
const creator = state.players.find((p) => p.id === me)!;
const enemy = state.players.find((p) => p.id !== me)!;
creator.life = 1;
enemy.position = { ...creator.position };
state = must(state, me, { type: "endTurn", draw: 0 });
state = must(state, enemy.id, { type: "punch", targetId: me });
state = must(state, me, { type: "pass" });
expect(state.players.find((p) => p.id === me)!.alive).toBe(false);
expect(state.creatures.length).toBe(0);
});
});
describe("expansion support cards", () => {
it("adrenaline allows a second attack in one turn", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
const adr = giveCard(state, attacker.id, "adrenaline");
giveCard(state, attacker.id, "number-2", "N", 1);
state = must(state, attacker.id, {
type: "cast", instanceId: adr.instanceId, numberInstanceIds: ["number-2#N"],
});
const fb1 = giveCard(state, attacker.id, "fireball", "F1", 0);
state = must(state, attacker.id, { type: "cast", instanceId: fb1.instanceId, target: { kind: "player", playerId: defender.id } });
state = must(state, defender.id, { type: "pass" });
const fb2 = giveCard(state, attacker.id, "fireball", "F2", 0);
state = must(state, attacker.id, { type: "cast", instanceId: fb2.instanceId, target: { kind: "player", playerId: defender.id } });
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(5);
// A third is refused.
const fb3 = giveCard(state, attacker.id, "fireball", "F3", 0);
expect(applyCommand(state, attacker.id, {
type: "cast", instanceId: fb3.instanceId, target: { kind: "player", playerId: defender.id },
}).ok).toBe(false);
});
it("shadow costs a life point per turn and dies to any damage", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "shadow");
state = r.state;
expect(state.creatures[0]!.kind).toBe("shadow");
const lifeBefore = state.players.find((p) => p.id === me)!.life;
// Around to my next turn: upkeep costs 1.
state = must(state, me, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(state.players.find((p) => p.id === me)!.life).toBe(lifeBefore - 1);
// Any damage destroys it.
const enemy = state.players.find((p) => p.id !== me)!.id;
state = must(state, me, { type: "endTurn", draw: 0 });
state.players.find((p) => p.id === enemy)!.position = { ...state.creatures[0]!.position };
const fb = giveCard(state, enemy, "fireball", "F", 0);
state = must(state, enemy, {
type: "cast", instanceId: fb.instanceId,
target: { kind: "creature", creatureId: state.creatures[0]!.id },
});
expect(state.creatures.length).toBe(0);
});
});
describe("counteracting a creature's blow", () => {
function freshGame() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
}
function wraithOnVictim(state: GameState) {
state = toRound2(state);
const controller = activePlayer(state);
const victim = state.players.find((p) => p.id !== controller.id)!;
const spot = emptyNeighborCell(state, controller.position);
state.creatures.push({
id: "w1", kind: "wraith", controllerId: controller.id, position: { ...controller.position },
damage: 0, maxDamage: 4, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 1, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
});
victim.position = { ...spot.cell };
return { state, controller: controller.id, victim: victim.id, side: spot.side };
}
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
let { state } = freshGame();
const rig = wraithOnVictim(state);
state = rig.state;
giveCard(state, rig.victim, "blunt", "B", 0);
const handBefore = state.players.find((p) => p.id === rig.victim)!.hand.filter(Boolean).length;
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
expect(state.stack?.creatureId).toBe("w1");
state = must(state, rig.victim, { type: "counteract", instanceId: "blunt#B" });
state = must(state, rig.controller, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
const v = state.players.find((p) => p.id === rig.victim)!;
expect(v.life).toBe(14); // 2 halved up to 1
expect(v.hand.length).toBe(handBefore - 2); // blunt spent + a card stolen
});
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
let { state } = freshGame();
const rig = wraithOnVictim(state);
state = rig.state;
giveCard(state, rig.victim, "full-reflection", "FR", 0);
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, rig.controller, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
const v = state.players.find((p) => p.id === rig.victim)!;
expect(v.life).toBe(15);
const wraith = state.creatures.find((c) => c.id === "w1")!;
expect(wraith.damage).toBe(2);
});
});
describe("big man", () => {
function bigRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const giant = activePlayer(state);
state.sustained.push({
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
remainingTurns: 9, data: {},
});
return { state, giant };
}
/** Any square with two open steps in a straight line (test surgery spot). */
function straightRun(state: GameState): { at: Cell; side: Side; mid: Cell; far: Cell } {
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const at = { x, y };
if (view.homes.some((h) => cellKey(h) === key)) continue;
for (const side of SIDES) {
const t1 = stepTarget(view, at, side);
if (t1.kind !== "step") continue;
const t2 = stepTarget(view, t1.to, side);
if (t2.kind !== "step") continue;
return { at, side, mid: t1.to, far: t2.to };
}
}
throw new Error("no straight corridor anywhere — impossible");
}
it("pushes a wizard down the corridor as he advances", () => {
const rig = bigRig();
let state = rig.state;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
const run = straightRun(state);
const giant = state.players.find((p) => p.id === rig.giant.id)!;
giant.position = { ...run.at };
victim.position = { ...run.mid };
state = must(state, rig.giant.id, { type: "move", direction: run.side });
const after = state.players.find((p) => p.id !== rig.giant.id)!;
expect(cellKey(after.position)).toBe(cellKey(run.far));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
});
/** From a giant's square: a step into a square whose exits (all sides but
* the way back) number exactly `want` — a corner (1, not straight) or a
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */
function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") {
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (view.homes.some((h) => cellKey(h) === key)) continue;
for (const side of SIDES) {
const t1 = stepTarget(view, { x, y }, side);
if (t1.kind !== "step") continue;
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "straight-fork" && exits.length >= 2 && exits.includes(side)) return { at: { x, y }, side, mid: t1.to, exits };
}
}
throw new Error(`no ${want} on this board`);
}
it("pushes a wizard around a corner when that is the only way (rev 15)", () => {
const rig = bigRig();
let state = rig.state;
const site = pushSite(state, "corner");
const giant = state.players.find((p) => p.id === rig.giant.id)!;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
giant.position = { ...site.at };
victim.position = { ...site.mid };
state = must(state, rig.giant.id, { type: "move", direction: site.side });
const after = state.players.find((p) => p.id !== rig.giant.id)!;
expect(cellKey(after.position)).toBe(cellKey(neighbor(site.mid, site.exits[0]!)));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
});
it("at a fork the pushed wizard chooses, and the stride hangs until they do", () => {
const rig = bigRig();
let state = rig.state;
const site = pushSite(state, "fork");
const giant = state.players.find((p) => p.id === rig.giant.id)!;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
giant.position = { ...site.at };
victim.position = { ...site.mid };
const used = state.turn.movementUsed;
const r = applyCommand(state, rig.giant.id, { type: "move", direction: site.side });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.pushPending?.pusheeId).toBe(victim.id);
expect(state.turn.movementUsed).toBe(used);
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.at));
// Only the pushed wizard may answer, and only with pushChoice, and only an open way.
expect(applyCommand(state, rig.giant.id, { type: "move", direction: site.side }).ok).toBe(false);
expect(applyCommand(state, victim.id, { type: "pass" }).ok).toBe(false);
expect(applyCommand(state, victim.id, { type: "pushChoice", direction: opposite(site.side) }).ok).toBe(false);
const pick = site.exits[site.exits.length - 1]!;
state = must(state, victim.id, { type: "pushChoice", direction: pick });
expect(state.pushPending).toBeNull();
expect(cellKey(state.players.find((p) => p.id !== rig.giant.id)!.position)).toBe(cellKey(neighbor(site.mid, pick)));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
expect(state.turn.movementUsed).toBe(used + 1);
});
it("a pre-rev-15 game shoves straight ahead when it can and rounds a corner when it cannot", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
state = toRound2(state);
const giant = activePlayer(state);
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} });
const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "straight-fork");
{
giant.position = { ...fork.at };
victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
if (!r.ok) throw new Error(r.error);
expect(r.state.pushPending).toBeNull();
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(fork.mid, fork.side)));
}
// A corner: refused before, rounded now.
const corner = pushSite(state, "corner");
giant.position = { ...corner.at };
victim.position = { ...corner.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: corner.side });
if (!r.ok) throw new Error(r.error);
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(corner.mid, corner.exits[0]!)));
});
it("no room to shove means no way forward", () => {
const rig = bigRig();
const state = rig.state;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
const view = boardView(state);
// A dead-end pocket beside some square: a victim there cannot be pushed anywhere.
const giant = state.players.find((p) => p.id === rig.giant.id)!;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const t1 = stepTarget(view, { x, y }, side);
if (t1.kind !== "step") continue;
if (SIDES.some((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step")) continue;
giant.position = { x, y };
victim.position = { ...t1.to };
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
return;
}
}
throw new Error("setup: no dead-end pocket on this board");
});
it("a shrunk wizard slips between the giant's boots", () => {
// bigRig() makes the ACTIVE player the giant; here the mover must act,
// so the giant takes the other seat.
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const mover = activePlayer(state);
const giant = state.players.find((p) => p.id !== mover.id)!;
state.sustained.push({
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
remainingTurns: 9, data: {},
});
const view = boardView(state);
const side = SIDES.find((s) => stepTarget(view, mover.position, s).kind === "step");
if (!side) throw new Error("setup: no open step beside the mover");
const t = stepTarget(view, mover.position, side);
if (t.kind !== "step") throw new Error("unreachable");
giant.position = { ...t.to };
// Full-size, the corridor is filled.
expect(applyCommand(state, mover.id, { type: "move", direction: side }).ok).toBe(false);
// Shrunk, the same step slips through.
state.sustained.push({
id: "fx-small", cardId: "shrink", casterId: mover.id, targetId: mover.id,
remainingTurns: 1, data: {},
});
const r = applyCommand(state, mover.id, { type: "move", direction: side });
if (!r.ok) throw new Error(r.error);
expect(cellKey(r.state.players.find((p) => p.id === mover.id)!.position)).toBe(cellKey(giant.position));
});
it("monsters cannot enter the giant's square", () => {
const rig = bigRig();
let state = rig.state;
const view = boardView(state);
const spot = emptyNeighborCell(state, rig.giant.position);
state.creatures.push({
id: "t1", kind: "troll", controllerId: state.players.find((p) => p.id !== rig.giant.id)!.id,
position: { ...spot.cell },
damage: 0, maxDamage: 8, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
});
state = must(state, rig.giant.id, { type: "endTurn", draw: 0 });
const controller = activePlayer(state).id;
const back = (["N", "S", "E", "W"] as const).find((d) => {
const t = stepTarget(view, spot.cell, d);
return t.kind === "step" && cellKey(t.to) === cellKey(rig.giant.position);
})!;
const r = applyCommand(state, controller, { type: "moveCreature", creatureId: "t1", direction: back });
expect(r.ok).toBe(false);
});
it("steps over a pit for two movement points, never entering it", () => {
const rig = bigRig();
let state = rig.state;
const run = straightRun(state);
state.players.find((p) => p.id === rig.giant.id)!.position = { ...run.at };
state.squareContents[cellKey(run.mid)] = { kind: "pit", damage: 0, createdBy: rig.giant.id };
state = must(state, rig.giant.id, { type: "move", direction: run.side, over: true });
const g = state.players.find((p) => p.id === rig.giant.id)!;
expect(cellKey(g.position)).toBe(cellKey(run.far));
expect(state.turn.movementUsed).toBe(2);
expect(g.inPit).toBe(false);
});
});
describe("monsters roll to hit the hidden", () => {
function creatureVsInvisible() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const me = activePlayer(state).id;
const victim = state.players.find((p) => p.id !== me)!;
const r = summon(state, me, "skeleton");
state = r.state;
const bones = state.creatures[0]!;
bones.justCreated = false;
bones.attackUsed = false;
bones.position = { ...victim.position };
state.sustained.push({
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
remainingTurns: 3, data: {},
});
return { state, me, victim, skeletonId: bones.id };
}
it("the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
let { state, me, victim, skeletonId } = creatureVsInvisible();
const rngBefore = JSON.stringify(state.rng);
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
// The die was consumed, whichever way it landed.
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
});
});
describe("elimination sweeps the board either way", () => {
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
const victim = state.players.find((p) => p.id === "c")!;
state.creatures.push({
id: "imp1", kind: "fire-imp", controllerId: "c",
position: { ...victim.position },
damage: 0, maxDamage: 5, movesPerTurn: 0, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
state.sustained.push({
id: "fx-med", cardId: "medusa", casterId: "a", targetId: "c",
remainingTurns: 3, data: {},
});
// Both of c's treasures sit captured on living enemies' home bases.
const mine = state.treasures.filter((t) => t.owner === "c");
expect(mine.length).toBe(2);
mine[0]!.position = { ...state.players.find((p) => p.id === "a")!.home };
mine[0]!.carriedBy = null;
mine[1]!.position = { ...state.players.find((p) => p.id === "b")!.home };
mine[1]!.carriedBy = null;
const active = activePlayer(state).id;
const r = applyCommand(state, active, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "c")!.alive).toBe(false);
expect(state.creatures.some((c) => c.controllerId === "c")).toBe(false);
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
});
});
describe("creatures walk open doorways", () => {
it("a skeleton passes a door whose lock was removed", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
state.doorStates[key] = "removed";
state.creatures.push({
id: "sk1", kind: "skeleton", controllerId: activePlayer(state).id,
position: { x, y }, damage: 0, maxDamage: 4, movesPerTurn: 3,
movementUsed: 0, attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, activePlayer(state).id, {
type: "moveCreature", creatureId: "sk1", direction: side,
});
if (!r.ok) throw new Error(r.error);
const sk = r.state.creatures.find((c) => c.id === "sk1")!;
expect(cellKey(sk.position)).toBe(cellKey({ x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) }));
return;
}
throw new Error("setup: seed 42 grew a maze with no doors");
});
});
describe("the wave carries monsters", () => {
it("a cornered skeleton takes the waterwall crush", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const caster = activePlayer(state);
// A skeleton pinned against solid stone, one square from the wave's edge.
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "wall") continue;
const [kind, coords] = key.split(":") as [string, string];
if (kind !== "H") continue;
const [x, y] = coords.split(",").map(Number) as [number, number];
// Wave from this wall southward washes the square below.
const below = { x, y: y + 1 };
if (!view.cells[cellKey(below)]) continue;
state.creatures.push({
id: "sk1", kind: "skeleton", controllerId: caster.id,
position: { ...below }, damage: 0, maxDamage: 4, movesPerTurn: 3,
movementUsed: 0, attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
// Pin it: stone directly south of it.
state.squareContents[cellKey({ x, y: y + 2 })] = { kind: "stone", damage: 0, createdBy: caster.id };
caster.position = { x, y };
const stw = giveCard(state, caster.id, "stone-to-water");
const r = applyCommand(state, caster.id, {
type: "cast", instanceId: stw.instanceId,
target: { kind: "edge", cell: { x, y }, side: "S" },
});
if (!r.ok) throw new Error(r.error);
const sk = r.state.creatures.find((c) => c.id === "sk1");
// Washed against the stone: it took crush damage (or died of it).
if (sk) expect(sk.damage).toBeGreaterThan(0);
return;
}
throw new Error("setup: no horizontal wall with a floor below");
});
});
describe("the democratic monster's claw survives the first wizard's death", () => {
it("refreshes each round even with the roll-off winner dead", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const firstId = state.players[state.turn.firstIndex]!.id;
const dm = {
id: "dm1", kind: "democratic-monster" as const, controllerId: state.players[0]!.id,
position: { x: 0, y: 0 }, damage: 0, maxDamage: 5, movesPerTurn: 3,
movementUsed: 0, attackUsed: true, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [] as string[],
};
state.creatures.push(dm);
// The roll-off winner falls.
const first = state.players.find((p) => p.id === firstId)!;
first.alive = false;
first.finalHand = [...first.hand];
// Walk a full round of the survivors: the claw must refresh.
for (let i = 0; i < 4 && state.creatures[0]!.attackUsed; i++) {
const active = activePlayer(state);
const r = applyCommand(state, active.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
expect(state.creatures[0]!.attackUsed).toBe(false);
});
});
describe("a mid-round democratic monster claws on the next turn", () => {
it("creation spends the turn, not the round", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const creator = activePlayer(state);
const r0 = summon(state, creator.id, "democratic-monster");
state = r0.state;
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
expect(dm.attackUsed).toBe(false);
expect(dm.justCreated).toBe(true);
// Next player's turn: justCreated clears; the claw is live.
state = must(state, creator.id, { type: "endTurn", draw: 0 });
const mover = activePlayer(state);
const victim = state.players.find((p) => p.id !== mover.id)!;
const fresh = state.creatures.find((c) => c.kind === "democratic-monster")!;
expect(fresh.justCreated).toBe(false);
// March it onto the victim: the touch must open.
fresh.position = { x: victim.position.x - 1, y: victim.position.y };
const r = applyCommand(state, mover.id, { type: "moveCreature", creatureId: fresh.id, direction: "E" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack?.creatureId ?? null).toBe(fresh.id);
});
});
describe("collapsing walls crush monsters too", () => {
function wallRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "wall") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
const other = { x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) };
if (!view.cells[cellKey(other)]) continue;
const caster = activePlayer(state);
caster.position = { x, y };
state.creatures.push({
id: "tr1", kind: "troll", controllerId: caster.id,
position: { ...other }, damage: 0, maxDamage: 6, movesPerTurn: 3,
movementUsed: 0, attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const dw = giveCard(state, caster.id, "destroy-wall");
const r = applyCommand(state, caster.id, {
type: "cast", instanceId: dw.instanceId, target: { kind: "edge", cell: { x, y }, side },
});
if (!r.ok) throw new Error(r.error);
return r.state;
}
throw new Error("setup: no interior wall found");
}
it("the adjacent troll takes the four points", () => {
const state = wallRig();
const troll = state.creatures.find((c) => c.id === "tr1");
// Four points on a six-point troll: hurt but standing (or regenerating).
expect(troll?.damage).toBe(4);
});
});
describe("the self-stack resolves on a pass", () => {
function selfTouch() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const creator = activePlayer(state);
state.creatures.push({
id: "dm1", kind: "democratic-monster", controllerId: creator.id,
position: { x: creator.position.x - 1, y: creator.position.y },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, creator.id, { type: "moveCreature", creatureId: "dm1", direction: "E" });
if (!r.ok) throw new Error(r.error);
return { state: r.state, creator: creator.id };
}
it("passing your own monster's touch takes the claw and moves on", () => {
const { state, creator } = selfTouch();
if (!state.stack) return; // a wall between: the touch never opened
expect(state.stack.attackerId).toBe(creator);
expect(state.stack.defenderId).toBe(creator);
const r = applyCommand(state, creator, { type: "pass" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack).toBeNull();
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
});
});
describe("fear holds off monsters and unwilling feet alike", () => {
it("a commanded monster cannot close within three of the fearsome", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!;
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
pushSustained(state, {
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
remainingTurns: 5, data: {},
});
b.position = { x: 2, y: 5 };
a.position = { x: 0, y: 0 };
state.creatures.push({
id: "troll-1", kind: "troll", controllerId: "a", position: { x: 2, y: 9 },
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
scorchedThisTurn: [],
});
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "a", { type: "moveCreature", creatureId: "troll-1", direction: "N" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("dread");
});
it("a wizard caged inside the dread may round a corner to escape", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!;
pushSustained(state, {
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
remainingTurns: 5, data: {},
});
// b radiates dread from (2,5). a stands in a dead-end pocket at
// (2,3): walls on three sides, so the only way out steps SOUTH —
// closer to b — before the corridor east leads clear of the dread.
b.position = { x: 2, y: 5 };
a.position = { x: 2, y: 3 };
for (const side of ["N", "E", "W"] as const) {
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall";
}
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r0.ok) throw new Error(r0.error);
state = r0.state;
}
// The closer step is the only way out: permitted.
const r = applyCommand(state, "a", { type: "move", direction: "S" });
if (!r.ok) throw new Error("escape step refused: " + r.error);
});
});
describe("creatures and the dimensional warp", () => {
it("a commanded troll steps through the warp tokens", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const you = state.players[state.turn.activeIndex]!.id;
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(true);
if (r.ok) {
const troll = r.state.creatures.find((c) => c.id === "troll-w")!;
expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 }));
expect(troll.movementUsed).toBe(1);
}
});
it("solid stone on the far side refuses the beast", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const you = state.players[state.turn.activeIndex]!.id;
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("stone");
});
});
describe("the imp's fire is a spell (rev 8)", () => {
function impRig() {
let { state } = createGame({ playerIds: ["imp-owner", "mark"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
while (activePlayer(state).id !== "imp-owner") {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
return state;
}
it("scorch opens a counteraction window and FULL SHIELD stops it", () => {
let state = impRig();
const owner = state.players.find((p) => p.id === "imp-owner")!;
const mark = state.players.find((p) => p.id === "mark")!;
mark.position = { ...owner.position };
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0);
const spot = emptyNeighborCell(state, owner.position);
let r = applyCommand(state, "imp-owner", {
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
});
if (!r.ok) throw new Error(r.error);
state = r.state;
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
// The scorch rides the stack now: a counteraction window, spell-kind,
// named for the imp so the counter is played against the scorch, never a punch.
expect(state.stack?.creatureId).toBeTruthy();
expect(state.stack?.kind).toBe("spell");
expect(state.stack?.sourceName).toBe("fire imp's scorch");
giveCard(state, "mark", "full-shield", "FS", 0);
const countered = applyCommand(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
if (!countered.ok) throw new Error(countered.error);
expect(countered.events).toContainEqual(expect.objectContaining({ type: "counteractionPlayed", against: "fire imp's scorch" }));
state = drain(countered.state);
expect(state.players.find((p) => p.id === "mark")!.life).toBe(15);
});
it("an uncountered scorch is dealt in the imp's name", () => {
let state = impRig();
const owner = state.players.find((p) => p.id === "imp-owner")!;
const mark = state.players.find((p) => p.id === "mark")!;
mark.position = { ...owner.position };
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0);
const spot = emptyNeighborCell(state, owner.position);
let r = applyCommand(state, "imp-owner", {
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
});
if (!r.ok) throw new Error(r.error);
state = r.state;
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
const done = applyCommand(state, "mark", { type: "pass" });
if (!done.ok) throw new Error(done.error);
expect(done.events).toContainEqual(expect.objectContaining({ type: "damaged", player: "mark", amount: 2, source: "fire imp's scorch" }));
});
it("soulstone's floor holds from below: a bearer at 2 takes nothing from the flame", () => {
let state = impRig();
const owner = state.players.find((p) => p.id === "imp-owner")!;
const mark = state.players.find((p) => p.id === "mark")!;
mark.position = { ...owner.position };
mark.life = 2;
mark.displayed.push("SS");
mark.hand[0] = { instanceId: "SS", cardId: "soulstone" };
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 1);
const spot = emptyNeighborCell(state, owner.position);
let r = applyCommand(state, "imp-owner", {
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
});
if (!r.ok) throw new Error(r.error);
state = r.state;
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
expect(state.stack?.kind).toBe("spell");
state = drain(state);
const after = state.players.find((p) => p.id === "mark")!;
expect(after.life).toBe(2);
expect(after.alive).toBe(true);
});
});
describe("the alter ego casts from its own square", () => {
it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => {
let { state } = newGame(42);
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const ego = giveCard(state, attacker, "alter-ego");
state = must(state, attacker, { type: "cast", instanceId: ego.instanceId });
const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!;
expect(cellKey(double.position)).toBe(cellKey(a.position));
// The caster steps out of the defender's sight; the double keeps it.
const view = boardView(state);
const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
expect(hidden).toBeDefined();
const me = state.players.find((p) => p.id === attacker)!;
me.position = { ...hidden! };
const fb = giveCard(state, attacker, "fireball");
const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(blind.ok).toBe(false);
const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" });
expect(wrong.ok).toBe(false);
const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id });
expect(viaEgo.ok).toBe(true);
if (!viaEgo.ok) return;
expect(viaEgo.state.stack?.attackerId).toBe(attacker);
expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position));
const cast = viaEgo.events.find((e) => e.type === "spellCast");
expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position));
const done = must(viaEgo.state, defender, { type: "pass" });
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
});
});
describe("the troll's fist on a wall", () => {
it("punches a wall line beside it for a D4, once a turn", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "troll");
state = r.state;
const other = state.players.find((p) => p.id !== me)!.id;
state = must(state, me, { type: "endTurn", draw: 0 });
state = must(state, other, { type: "endTurn", draw: 0 });
const troll = state.creatures[0]!;
const board = boardView(state);
const side = SIDES.find((s) => board.edges[edgeKey(troll.position, s)] === "wall")!;
const res = applyCommand(state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
expect(res.ok).toBe(true);
if (!res.ok) return;
const hit = res.events.find((e) => e.type === "wallDamaged");
expect(hit && hit.type === "wallDamaged" ? hit.amount : 0).toBeGreaterThanOrEqual(1);
expect(res.state.creatures[0]!.attackUsed).toBe(true);
const again = applyCommand(res.state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
expect(again.ok).toBe(false);
});
});