Files
wizwar6e/packages/engine/test/expansion-combat.test.ts
T
Eric WagonerandClaude Opus 5.5 3a6b0ff43e Credibility pass: the illusion tests roll both ways, and the seams from the last batch are sanded
Scope: 66b5c06..HEAD.

- The rev-27 illusion tests search seeds for a believed and a doubted
  roll and assert fixed life totals; seed 42 alone never believed, so
  the BLUNT pricing went unchecked.
- Three docblocks stranded above newly inserted functions are back on
  their own (openCreatureStack, resolvedBlow, castTrace).
- POWER ATTACK's life trade is one helper instead of three copies; the
  goat's charge distance is one helper for its validator, its ram and
  its pricing.
- The family list loads once into a shared store; the lobby and gallery
  share one talk filter; Board uses the engine's neighbor().
- A creature's blow shows the card that summoned it by the creature's
  kind, not by matching the blow's display name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-26 00:22:20 -04:00

1001 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
import { cellKey, edgeKey, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
describe("expansion combat cards", () => {
it("power attack burns life for extra damage", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
giveCard(state, attacker, "power-attack", "PA", 1);
state = castAt(state, attacker, defender, fb, {
powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 3,
});
expect(state.players.find((p) => p.id === defender)!.life).toBe(7); // 5+3
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
});
it("weakness doubles damage taken and forbids carrying treasure", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wk = giveCard(state, attacker, "weakness");
giveCard(state, attacker, "number-3", "N", 1);
state = castAt(state, attacker, defender, wk, { numberInstanceIds: ["number-3#N"] });
expect(sustainedOn(state, defender, "weakness").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
const t = state.treasures.find((t) => t.owner === attacker && t.position)!;
const d = state.players.find((p) => p.id === defender)!;
d.position = { ...t.position! };
expect(applyCommand(state, defender, { type: "pickUpTreasure" }).ok).toBe(false);
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
const d2 = state.players.find((p) => p.id === defender)!;
d2.position = { ...state.players.find((p) => p.id === attacker)!.position };
state = castAt(state, attacker, defender, fb);
expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5x2
});
it("walking dead bleeds half a point per space walked", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wd = giveCard(state, attacker, "walking-dead");
state = castAt(state, attacker, defender, wd);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Defender walks: every second step costs a point.
let lifeStart = state.players.find((p) => p.id === defender)!.life;
let steps = 0;
for (const dir of ["N", "S", "E", "W", "N", "S"] as const) {
const r = applyCommand(state, defender, { type: "move", direction: dir });
if (r.ok) { state = r.state; steps++; }
if (steps === 2) break;
}
expect(steps).toBe(2);
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
});
it("a reversed walking dead heals half a point per space walked (rev 11)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wd = giveCard(state, attacker, "walking-dead");
giveCard(state, defender, "reverse", "RV", 0);
const r = applyCommand(state, attacker, {
type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender },
});
if (!r.ok) throw new Error(r.error);
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
state = drain(state);
expect(sustainedOn(state, defender, "walking-dead").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Two spaces walked: one point gained, not bled.
let steps = 0;
for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) {
if (steps >= 2) break;
const mv = applyCommand(state, defender, { type: "move", direction: dir });
if (mv.ok) { state = mv.state; steps++; }
}
expect(steps).toBe(2);
expect(state.players.find((p) => p.id === defender)!.life).toBe(16);
});
it("mad dash doubles the base, the riding number, and later fuel (rev 12)", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const md = giveCard(state, me, "mad-dash");
giveCard(state, me, "exp1-number-5", "N5", 1);
state = must(state, me, {
type: "cast", instanceId: md.instanceId, numberInstanceIds: ["exp1-number-5#N5"],
});
expect(state.turn.movementAllowance).toBe(16); // (3 + 5) × 2
// The rider was the turn's movement number: a bare second is refused.
giveCard(state, me, "number-2", "N2", 0);
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2" }).ok).toBe(false);
// POWER RUN points double under the dash too.
const pr = giveCard(state, me, "power-run", "PR", 2);
state = must(state, me, { type: "cast", instanceId: pr.instanceId, params: { points: 2 } });
expect(state.turn.movementAllowance).toBe(20); // 16 + 2×2
});
it("a number played after a bare mad dash doubles as well (rev 12)", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const md = giveCard(state, me, "mad-dash");
state = must(state, me, { type: "cast", instanceId: md.instanceId });
expect(state.turn.movementAllowance).toBe(6); // 3 × 2
giveCard(state, me, "exp1-number-5", "N5", 0);
state = must(state, me, { type: "playNumberForMovement", instanceId: "exp1-number-5#N5" });
expect(state.turn.movementAllowance).toBe(16); // 6 + 5×2
});
it("older decks double only the base and eat the rider (rev ≤11)", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 11 });
state = toRound2(state);
const me = activePlayer(state).id;
const md = giveCard(state, me, "mad-dash");
giveCard(state, me, "exp1-number-5", "N5", 1);
state = must(state, me, {
type: "cast", instanceId: md.instanceId, numberInstanceIds: ["exp1-number-5#N5"],
});
expect(state.turn.movementAllowance).toBe(6); // 3 × 2, the five wasted
});
it("mental swap trades entire hands", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ms = giveCard(state, attacker, "mental-swap");
const aCards = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
const dCards = state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId);
state = castAt(state, attacker, defender, ms);
const aAfter = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
expect(aAfter).toEqual(dCards);
// (the swap card itself was consumed from the attacker's hand pre-swap)
expect(state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId))
.toEqual(aCards.filter((id) => id !== ms.instanceId));
});
it("butt-head rams for the distance charged (legacy rev 9 rules)", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 9 });
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand them 3 apart on the same column if possible; else same square +N.
defender.position = { x: attacker.position.x, y: attacker.position.y >= 3 ? attacker.position.y - 3 : attacker.position.y + 3 };
const bh = giveCard(state, attacker.id, "butt-head");
state = castAt(state, attacker.id, defender.id, bh);
const a = state.players.find((p) => p.id === attacker.id)!;
const d = state.players.find((p) => p.id === defender.id)!;
expect(cellKey(a.position)).toBe(cellKey(d.position));
expect(d.life).toBe(12); // 3 spaces = 3 damage
});
it("empathy turns an attack back on its caster as well", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
// Defender raises empathy on their own turn.
state = must(state, attacker, { type: "endTurn", draw: 0 });
const em = giveCard(state, defender, "empathy", "E", 0);
giveCard(state, defender, "number-3", "N", 1);
state = must(state, defender, {
type: "cast", instanceId: em.instanceId, numberInstanceIds: ["number-3#N"],
});
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
state = castAt(state, attacker, defender, fb);
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("ward springs in the moment: the grab hangs on the owner's choice", () => {
let { state } = newGame();
const me = activePlayer(state);
const enemy = state.players.find((p) => p.id !== me.id)!;
giveCard(state, enemy.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
me.position = { ...treasure.position! };
state = must(state, me.id, { type: "pickUpTreasure" });
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
state = must(state, enemy.id, { type: "wardChoice", play: true });
state = drain(state);
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("opportunity fire opens an out-of-turn attack window", () => {
let { state } = newGame();
state = toRound2(state);
const active = activePlayer(state).id;
const lurker = state.players.find((p) => p.id !== active)!;
lurker.position = { ...state.players.find((p) => p.id === active)!.position };
const of_ = giveCard(state, lurker.id, "opportunity-fire", "OF", 0);
const fb = giveCard(state, lurker.id, "fireball", "F", 1);
// Out of turn: play opportunity fire, then the attack.
state = must(state, lurker.id, { type: "cast", instanceId: of_.instanceId });
expect(state.outOfTurnWindow?.playerId).toBe(lurker.id);
state = must(state, lurker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: active },
});
state = must(state, active, { type: "pass" });
expect(state.players.find((p) => p.id === active)!.life).toBe(10);
// Turn structure is intact: the original player is still active.
expect(activePlayer(state).id).toBe(active);
expect(state.outOfTurnWindow).toBeNull();
});
it("idiot marches its victim toward their own treasure and forbids casting", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Casting is refused; moving is steered (any direction request works).
const fb = giveCard(state, defender, "fireball", "F", 0);
expect(applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
}).ok).toBe(false);
const before = state.players.find((p) => p.id === defender)!.position;
const r = applyCommand(state, defender, { type: "move", direction: "N" });
if (r.ok) {
const after = r.state.players.find((p) => p.id === defender)!.position;
expect(cellKey(after)).not.toBe(cellKey(before));
} else {
expect(r.error).toMatch(/blocked/);
}
});
it("idiot lifts the moment the victim's last floor treasure is carried off", () => {
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic"] });
while (state.turn.round < 2) {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const { attacker, defender } = faceOff(state);
const third = state.players.find((p) => p.id !== attacker && p.id !== defender)!;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
// A third wizard hauls off one of the victim's chests: one destination
// remains, so the march goes on.
const [first, second] = state.treasures.filter((t) => t.owner === defender);
const hauler = state.players.find((p) => p.id === third.id)!;
first!.carriedBy = hauler.id;
first!.position = null;
hauler.carriedTreasureId = first!.id;
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
// The caster takes the last one off the floor — nothing left to march
// to, and the curse lifts on the spot.
const att = state.players.find((p) => p.id === attacker)!;
att.position = { ...second!.position! };
state = must(state, attacker, { type: "pickUpTreasure", treasureId: second!.id });
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
});
it("idiot forbids item handling and punches but allows counteractions", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
// The victim carries an OPPONENT'S treasure into the spell.
const d = state.players.find((p) => p.id === defender)!;
const stolen = state.treasures.find((t) => t.owner === attacker)!;
stolen.carriedBy = defender;
stolen.position = null;
d.carriedTreasureId = stolen.id;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// No dropping the carried treasure (no capturing it on your home, either).
const drop = applyCommand(state, defender, { type: "dropTreasure" });
expect(drop.ok).toBe(false);
if (!drop.ok) expect(drop.error).toMatch(/treasure/);
// No punching the tormentor.
expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false);
// Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall").
const sp = giveCard(state, defender, "speed", "SP", 0);
expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true);
// Counteractions are expressly allowed: absorb an incoming fireball.
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
giveCard(state, defender, "absorb-spell", "AB", 1);
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
});
it("idiot never steers the march — the step goes where asked", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
const d = state.players.find((p) => p.id === defender)!;
const board = boardView(state);
const open = (["N", "S", "E", "W"] as const)
.filter((s) => stepTarget(board, d.position, s).kind === "step");
expect(open.length).toBeGreaterThanOrEqual(2);
// Plant the victim's own gold one step out one way; walk the other way.
const toward = stepTarget(board, d.position, open[0]!);
const away = stepTarget(board, d.position, open[1]!);
if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable");
const own = state.treasures.find((t) => t.owner === defender)!;
own.carriedBy = null;
own.position = toward.to;
state = must(state, defender, { type: "move", direction: open[1]! });
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
});
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const thief = state.players.find((p) => p.id === attacker)!;
const own = state.treasures.find((t) => t.owner === defender)!;
own.carriedBy = attacker;
own.position = null;
thief.carriedTreasureId = own.id;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// An attack for its own sake stays forbidden.
const fb = giveCard(state, defender, "fireball", "F", 0);
expect(applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
}).ok).toBe(false);
// Shaking YOUR OWN gold out of the thief's arms serves the march —
// while carried it cannot be stood upon.
const dr = giveCard(state, defender, "drop-object", "D", 1);
const r = applyCommand(state, defender, {
type: "cast", instanceId: dr.instanceId,
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
});
expect(r.ok).toBe(true);
});
it("idiot has no effect on a victim carrying their own treasure", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const own = state.treasures.find((t) => t.owner === defender)!;
own.carriedBy = defender;
own.position = null;
d.carriedTreasureId = own.id;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
});
});
describe("swap home bases", () => {
it("trades homes when both bases hold equal treasures", () => {
let { state } = newGame();
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
other.position = { ...me.position }; // LOS guaranteed
const myHome = { ...me.home };
const theirHome = { ...other.home };
const shb = giveCard(state, me.id, "swap-home-bases");
state = must(state, me.id, {
type: "cast", instanceId: shb.instanceId, target: { kind: "player", playerId: other.id },
});
expect(cellKey(state.players.find((p) => p.id === me.id)!.home)).toBe(cellKey(theirHome));
expect(cellKey(state.players.find((p) => p.id === other.id)!.home)).toBe(cellKey(myHome));
});
});
describe("thumb of god (divine meteor)", () => {
it("scatters every token near where the die lands", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
// Sprinkle the blast zone: a ground object and the enemy nearby.
const enemy = state.players.find((p) => p.id !== me.id)!;
enemy.position = { ...me.position };
state.groundObjects[cellKey(me.position)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
const tog = giveCard(state, me.id, "thumb-of-god");
state = must(state, me.id, {
type: "cast", instanceId: tog.instanceId, target: { kind: "cell", cell: me.position },
});
expect(state.turn.attackUsed).toBe(true);
// The dagger moved somewhere on the board.
const allObjects = Object.values(state.groundObjects).flat();
expect(allObjects.some((c) => c.cardId === "dagger")).toBe(true);
});
});
describe("add for movement", () => {
it("an ADD permits a second number card on movement, once", () => {
let { state } = newGame();
const me = activePlayer(state).id;
giveCard(state, me, "number-3", "N1", 0);
giveCard(state, me, "number-2", "N2", 1);
giveCard(state, me, "add", "A", 2);
giveCard(state, me, "number-4", "N3", 3);
state = must(state, me, { type: "playNumberForMovement", instanceId: "number-3#N1" });
expect(state.turn.movementAllowance).toBe(6);
// Second without ADD: refused.
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2" }).ok).toBe(false);
// With ADD: allowed.
state = must(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2", addInstanceId: "add#A" });
expect(state.turn.movementAllowance).toBe(8);
// A third is refused even with another add wished for.
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-4#N3" }).ok).toBe(false);
});
});
describe("ambushes (async interrupts)", () => {
it("an armed Opportunity Fire springs when prey walks into sight", () => {
let { state } = newGame();
state = toRound2(state);
const owner = activePlayer(state);
const of_ = giveCard(state, owner.id, "opportunity-fire", "OF", 0);
const fb = giveCard(state, owner.id, "fireball", "FB", 1);
state = must(state, owner.id, {
type: "setAmbush", instanceId: of_.instanceId, trigger: { kind: "los" },
spellInstanceId: fb.instanceId,
});
const o = state.players.find((p) => p.id === owner.id)!;
expect(o.hand.some((c) => c.cardId === "fireball")).toBe(false);
expect(state.ambushes.length).toBe(1);
state = must(state, owner.id, { type: "endTurn", draw: 0 });
const preyNow = state.players.find((p) => p.id !== owner.id)!;
const ownerNow = state.players.find((p) => p.id === owner.id)!;
// Find a step that goes from a no-LOS cell into a LOS cell.
let found: { from: { x: number; y: number }; side: "N" | "S" | "E" | "W" } | null = null;
outer: for (const key of Object.keys(state.board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (!gameLos(state, ownerNow.position, cell)) continue;
for (const side of ["N", "S", "E", "W"] as const) {
const dx = side === "E" ? 1 : side === "W" ? -1 : 0;
const dy = side === "S" ? 1 : side === "N" ? -1 : 0;
const from = { x: x - dx, y: y - dy };
if (!state.board.cells[cellKey(from)]) continue;
if (gameLos(state, ownerNow.position, from)) continue;
const st = stepTarget(boardView(state), from, side);
if (st.kind === "step" && cellKey(st.to) === key) { found = { from, side }; break outer; }
}
}
expect(found).not.toBeNull();
preyNow.position = found!.from;
state = must(state, preyNow.id, { type: "move", direction: found!.side });
expect(state.stack).not.toBeNull();
expect(state.stack!.attackerId).toBe(owner.id);
expect(state.stack!.attackCard!.cardId).toBe("fireball");
expect(state.ambushes.length).toBe(0);
state = must(state, preyNow.id, { type: "pass" });
expect(state.players.find((p) => p.id === preyNow.id)!.life).toBe(10);
});
it("cancelling an ambush returns the committed cards", () => {
let { state } = newGame();
const owner = activePlayer(state);
const int_ = giveCard(state, owner.id, "interrupt", "I", 0);
const lb = giveCard(state, owner.id, "lightning-blast", "LB", 1);
giveCard(state, owner.id, "number-3", "N", 2);
state = must(state, owner.id, {
type: "setAmbush", instanceId: int_.instanceId, trigger: { kind: "near" },
spellInstanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
});
const id = state.ambushes[0]!.id;
state = must(state, owner.id, { type: "cancelAmbush", ambushId: id });
const o = state.players.find((p) => p.id === owner.id)!;
expect(o.hand.some((c) => c.cardId === "interrupt")).toBe(true);
expect(o.hand.some((c) => c.cardId === "lightning-blast")).toBe(true);
expect(state.ambushes.length).toBe(0);
});
});
describe("strength tears treasures from wizards' arms", () => {
function grip(seed: number) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
// The defender carries the ATTACKER's own treasure (no ward window).
const stolen = state.treasures.find((t) => t.owner === attacker)!;
stolen.carriedBy = defender;
stolen.position = null;
d.carriedTreasureId = stolen.id;
const st = giveCard(state, attacker, "strength");
giveCard(state, attacker, "number-2", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
});
return { state, attacker, defender, treasure: stolen.id };
}
it("the grip is an attack: the victim keeps the treasure only on a 1", () => {
let torn = 0, kept = 0;
for (let seed = 1; seed <= 12; seed++) {
const { state, attacker, defender, treasure } = grip(seed);
let r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
if (!r.ok) throw new Error(r.error);
// Rev 6: the grab waits out its counteraction window first.
expect(r.state.stack?.tearTreasure).toBe(true);
r = applyCommand(r.state, defender, { type: "pass" });
if (!r.ok) throw new Error(r.error);
expect(r.state.turn.attackUsed).toBe(true);
// FAQ: tearing is not picking up — the turn's actions continue.
expect(r.state.turn.actionsEnded).toBe(false);
const a = r.state.players.find((p) => p.id === attacker)!;
const d = r.state.players.find((p) => p.id === defender)!;
if (a.carriedTreasureId === treasure) {
torn++;
expect(d.carriedTreasureId).toBeNull();
} else {
kept++;
expect(d.carriedTreasureId).toBe(treasure);
}
// One attack per turn: a second wrench is refused.
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
}
expect(torn).toBeGreaterThan(0);
expect(kept).toBeGreaterThan(0);
});
it("without STRENGTH the grip is refused", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const t = state.treasures.find((t) => t.owner === attacker)!;
t.carriedBy = defender;
t.position = null;
d.carriedTreasureId = t.id;
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
expect(r.ok).toBe(false);
});
it("laden arms tear it loose onto the floor — and the ward's owner gets their window", () => {
for (let seed = 1; seed <= 12; seed++) {
let { state } = createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
state = toRound2(state);
while (activePlayer(state).id !== "alice") {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const alice = state.players.find((p) => p.id === "alice")!;
const bob = state.players.find((p) => p.id === "bob")!;
bob.position = { ...alice.position };
// Alice already hauls her own gold; Bob carries CARA's — and Cara
// holds a WARD over it.
const mine = state.treasures.find((t) => t.owner === "alice")!;
mine.carriedBy = "alice";
mine.position = null;
alice.carriedTreasureId = mine.id;
const caras = state.treasures.find((t) => t.owner === "cara")!;
caras.carriedBy = "bob";
caras.position = null;
bob.carriedTreasureId = caras.id;
giveCard(state, "cara", "ward", "W", 0);
const st = giveCard(state, "alice", "strength");
giveCard(state, "alice", "number-2", "N", 1);
state = must(state, "alice", {
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
});
let r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
if (!r.ok) throw new Error(r.error);
// Rev 6: the grab waits out its counteraction window first.
r = applyCommand(r.state, "bob", { type: "pass" });
if (!r.ok) throw new Error(r.error);
const after = r.state.treasures.find((t) => t.id === caras.id)!;
if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again
// Torn loose: Alice's arms are full, so it lands at her feet…
expect(after.position).toEqual(alice.position);
expect(r.state.players.find((p) => p.id === "alice")!.carriedTreasureId).toBe(mine.id);
// …and the seizure hangs on Cara's ward.
expect(r.state.wardPending).toEqual({ ownerId: "cara", takerId: "alice" });
return;
}
throw new Error("every seed rolled a 1 — the rig is wrong");
});
});
describe("swap meet trades carried items", () => {
function rig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
giveCard(state, defender, "blaster-wand", "W", 0);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "dagger;blaster-wand" },
});
state = must(state, defender, { type: "pass" });
return { state, attacker, defender };
}
it("a dagger trades for a wand — both are carried items", () => {
const { state, attacker, defender } = rig();
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
});
describe("swap meet trades treasures too", () => {
function treasureRig(theirsToken: string, mineToken: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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)!;
// The defender hauls one of the attacker's own treasures.
const stolen = state.treasures.find((t) => t.owner === attacker)!;
stolen.position = null; stolen.carriedBy = defender; d.carriedTreasureId = stolen.id;
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: `${mineToken};${theirsToken}` },
});
state = must(state, defender, { type: "pass" });
return { state, attacker, defender, a, d, stolen };
}
it("a dagger buys back the treasure in the thief's arms", () => {
const { state, attacker, defender } = treasureRig("treasure", "dagger");
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const t = state.treasures.find((tr) => tr.owner === attacker && tr.carriedBy)!;
expect(a.carriedTreasureId).toBe(t.id);
expect(t.carriedBy).toBe(attacker);
expect(d.carriedTreasureId).toBeNull();
expect(d.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
it("the trade refuses to overload full arms", () => {
// The attacker also carries a treasure: claiming theirs with a dagger
// would mean two in hand — the swap quietly cannot happen.
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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 t1 = state.treasures.find((t) => t.owner === attacker)!;
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
const t2 = state.treasures.find((t) => t.owner === defender)!;
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "dagger;treasure" },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t1.id);
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t2.id);
});
it("treasure for treasure trades both armfuls", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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 t1 = state.treasures.find((t) => t.owner === attacker)!;
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
const t2 = state.treasures.find((t) => t.owner === defender)!;
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
const sm = giveCard(state, attacker, "swap-meet");
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "treasure;treasure" },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t2.id);
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t1.id);
expect(state.treasures.find((t) => t.id === t1.id)!.carriedBy).toBe(defender);
expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker);
});
});
describe("full reflection hands the swap meet choice to the reflector", () => {
function reflectedSwapRig(reflectorChoice?: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
// The caster carries a treasure and offers a dagger; the reflector may
// instead demand the trade of their own choosing.
const t = state.treasures.find((t) => t.owner === attacker)!;
t.position = null; t.carriedBy = attacker; a.carriedTreasureId = t.id;
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
giveCard(state, defender, "full-reflection", "FR", 0);
giveCard(state, defender, "large-rock", "R", 1);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "dagger;large-rock" },
});
state = must(state, defender, {
type: "counteract", instanceId: "full-reflection#FR",
...(reflectorChoice ? { params: { cardId: reflectorChoice } } : {}),
});
state = must(state, attacker, { type: "pass" });
return { state, attacker, defender, treasureId: t.id };
}
it("the reflector rewrites the trade: their rock for the caster's treasure", () => {
const { state, attacker, defender, treasureId } = reflectedSwapRig("large-rock;treasure");
const d = state.players.find((p) => p.id === defender)!;
expect(d.carriedTreasureId).toBe(treasureId);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBeNull();
});
it("the reflector may decline: 'none' trades nothing", () => {
const { state, attacker, defender, treasureId } = reflectedSwapRig("none");
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(treasureId);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
});
it("a bare full reflection with no choice trades nothing", () => {
const { state, attacker } = reflectedSwapRig(undefined);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
});
describe("go away routs around walls", () => {
it("a victim against a wall bends the line instead of standing still", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
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)!;
// Attacker west of the victim, a wall hard against the victim's east:
// the straight line away is blocked from the first step.
a.position = { x: 1, y: 5 };
d.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 1, y: 5 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "E")] = "wall";
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "N")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "S")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "N")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 6 }, "S")] = "open";
const ga = giveCard(state, attacker, "go-away");
giveCard(state, attacker, "number-3", "N3", 1);
state = must(state, attacker, {
type: "cast", instanceId: ga.instanceId, numberInstanceIds: ["number-3#N3"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
const dist = Math.abs(after.position.x - 1) + Math.abs(after.position.y - 5);
// Three routed spaces: strictly farther than where they stood.
expect(dist).toBeGreaterThanOrEqual(3);
expect(after.lostTurns).toBe(1);
});
});
describe("the goat charges on legs, not wings (rev 10)", () => {
it("the charge walks real corridors: in reach it spends legs, beyond them it is refused", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
const bh = giveCard(state, attacker.id, "butt-head");
// The engine is the oracle: every other square is either within the
// turn's legal movement or beyond it, and this maze holds both kinds.
let refusals = 0;
let rams = 0;
for (const key of Object.keys(boardView(state).cells)) {
if (key === cellKey(attacker.position)) continue;
const [x, y] = key.split(",").map(Number) as [number, number];
defender.position = { x, y };
const r = applyCommand(state, attacker.id, {
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
});
if (!r.ok) {
if (/legs, not wings/.test(r.error)) refusals += 1;
continue;
}
const st = drain(r.state);
const a = st.players.find((p) => p.id === attacker.id)!;
expect(a.position).toEqual(st.players.find((p) => p.id === defender.id)!.position);
expect(st.turn.movementUsed).toBeGreaterThan(0);
rams += 1;
}
expect(refusals).toBeGreaterThan(0);
expect(rams).toBeGreaterThan(0);
});
});
describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
const view = boardView(state);
const cells = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.filter((c) => !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
for (const from of cells) for (const to of cells) {
const d = walkingDistance(state, from, to, 12);
if (d === 7 || d === 8) return { from, to, steps: d };
}
throw new Error("no seven-step corridor on this board");
}
for (const [deckRev, ok] of [[16, true], [15, false]] as const) {
it(`a goat with eight legs and a target seven or eight corridor steps off: rev ${deckRev} ${ok ? "charges" : "refuses"}`, () => {
let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const goat = activePlayer(state);
const mark = state.players.find((p) => p.id !== goat.id)!;
const run = longRun(state);
goat.position = { ...run.from };
mark.position = { ...run.to };
state.turn.movementAllowance = 8;
state.turn.movementUsed = 0;
const card = giveCard(state, goat.id, "butt-head");
const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } });
expect(r.ok).toBe(ok);
if (!r.ok) expect(r.error).toMatch(/legs, not wings/);
});
}
});
describe("cards that keep their whole promise (rev 21)", () => {
it("LIFESAVER's holder survives losing both treasures", () => {
for (const [deckRev, alive] of [[21, true], [20, false]] as const) {
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const others = state.players.filter((p) => p.id !== me.id);
const card = giveCard(state, me.id, "lifesaver");
state = must(state, me.id, { type: "cast", instanceId: card.instanceId });
// Both of the holder's treasures rest on two different enemy homes: nobody wins, the holder is done for.
const mine = state.treasures.filter((t) => t.owner === me.id);
mine[0]!.position = { ...others[0]!.home };
mine[1]!.position = { ...others[1]!.home };
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(state.players.find((p) => p.id === me.id)!.alive).toBe(alive);
}
});
it("FORCE FIELD stands until the opponent's turn ends: no entering, no casting on its caster", () => {
for (const deckRev of [21, 20]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
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)!;
// Stand the attacker beside the defender, an open edge between.
const view = boardView(state);
const side = SIDES.find((s: Side) => { const t = stepTarget(view, d.position, s); return t.kind === "step" && !state.squareContents[cellKey(t.to)]; })!;
const beside = stepTarget(view, d.position, side);
if (beside.kind === "blocked") throw new Error("no open side");
a.position = { ...beside.to };
const fb = giveCard(state, attacker, "fireball");
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
state = must(state, attacker, { type: "pass" });
if (state.stack) state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
const step = applyCommand(state, attacker, { type: "move", direction: opposite(side) });
if (deckRev >= 21) {
expect(step.ok).toBe(false);
if (!step.ok) expect(step.error).toMatch(/force field/);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(true);
const after = must(state, attacker, { type: "endTurn", draw: 0 });
expect(after.sustained.some((f) => f.cardId === "force-field")).toBe(false);
} else {
expect(step.ok).toBe(true);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(false);
}
}
});
});
describe("the goat's ram is a physical blow like any other (rev 26)", () => {
/** A goat and its mark a charge apart, the goat holding STRENGTH, AMPLIFY, and the ram. */
function charged(deckRev?: number) {
let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
state = toRound2(state);
const goat = activePlayer(state);
const mark = state.players.find((p) => p.id !== goat.id)!;
const bh = giveCard(state, goat.id, "butt-head");
giveCard(state, goat.id, "amplify", "A", 1);
giveCard(state, goat.id, "strength", "S", 2);
giveCard(state, goat.id, "number-2", "N", 3);
state = must(state, goat.id, { type: "cast", instanceId: "strength#S", numberInstanceIds: ["number-2#N"] });
// Any square the charge reaches at two or more steps will do.
for (const key of Object.keys(boardView(state).cells)) {
if (key === cellKey(goat.position)) continue;
const [x, y] = key.split(",").map(Number) as [number, number];
const trial = structuredClone(state);
trial.players.find((p) => p.id === mark.id)!.position = { x, y };
const r = applyCommand(trial, goat.id, {
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: mark.id }, amplifyInstanceIds: ["amplify#A"],
});
if (!r.ok) continue;
const done = applyCommand(r.state, mark.id, { type: "pass" });
if (!done.ok) continue;
const ram = done.events.find((e) => e.type === "rammed");
if (!ram || ram.type !== "rammed" || ram.distance < 2) continue;
return { events: done.events, distance: ram.distance, state: done.state, goat: goat.id, mark: mark.id };
}
throw new Error("no square a charge of two or more reaches on this board");
}
it("STRENGTH and AMPLIFY each double the ram", () => {
const { events, distance, state, mark } = charged();
expect(events).toContainEqual(expect.objectContaining({ type: "damaged", player: mark, amount: distance * 4 }));
expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance * 4);
});
it("older games dealt the bare distance, whatever rode the cast", () => {
const { events, distance, state, mark } = charged(25);
expect(events).toContainEqual(expect.objectContaining({ type: "damaged", player: mark, amount: distance }));
expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance);
});
});
describe("an illusion is priced as the spell it fakes (rev 27)", () => {
/** A faked FIREBALL met by the given counteraction, then both wizards pass. */
function fakedFireball(counter: string | null, deckRev?: number, seed = 42) {
let { state } = createGame({ playerIds: ["liar", "mark"], seed, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ill = giveCard(state, attacker, "illusionary-attack");
if (counter) giveCard(state, defender, counter, "C", 0);
state = must(state, attacker, {
type: "cast", instanceId: ill.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: "fireball" },
});
let done = applyCommand(state, defender, { type: "pass" });
if (counter) {
state = must(state, defender, { type: "counteract", instanceId: `${counter}#C` });
// The attacker's pass settles a total stop at once; otherwise the
// defender's pass closes the window.
done = applyCommand(state, attacker, { type: "pass" });
if (done.ok && done.state.stack) done = applyCommand(done.state, defender, { type: "pass" });
}
if (!done.ok) throw new Error(done.error);
const belief = done.events.find((e) => e.type === "illusionBelieved");
const believed = belief?.type === "illusionBelieved" ? belief.believed : null;
return { events: done.events, believed, life: done.state.players.find((p) => p.id === defender)!.life };
}
/** The first seed whose die comes up each way: one believed, one disbelieved. */
function bothWays(counter: string | null, deckRev?: number) {
const found: Partial<Record<"believed" | "doubted", ReturnType<typeof fakedFireball>>> = {};
for (let seed = 1; seed <= 40 && !(found.believed && found.doubted); seed++) {
const r = fakedFireball(counter, deckRev, seed);
expect(r.believed).not.toBeNull();
found[r.believed ? "believed" : "doubted"] ??= r;
}
return { believed: found.believed!, doubted: found.doubted! };
}
it("BLUNT halves a believed fake fireball to three, and disbelief costs nothing", () => {
const { believed, doubted } = bothWays("blunt");
expect(believed.events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 }));
expect(believed.life).toBe(12);
expect(doubted.life).toBe(15);
});
it("FULL SHIELD stops the fake outright: nothing to believe in, no die rolled", () => {
const { events, believed, life } = fakedFireball("full-shield");
expect(believed).toBeNull();
expect(events).toContainEqual(expect.objectContaining({ type: "attackResolved", fullyStopped: true }));
expect(life).toBe(15);
});
it("uncountered, a believed fake fireball still does its five", () => {
expect(bothWays(null).believed.life).toBe(10);
});
it("older games dealt a believed illusion's full points past a BLUNT", () => {
expect(bothWays("blunt", 26).believed.life).toBe(10);
});
});