Files
wizwar6e/packages/engine/test/casting.test.ts
Eric WagonerandClaude Fable 5 3bd7e3c81d Credibility pass: the duels leave the code, staying in the ledgers
Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.

Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.

Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.

Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.

Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.

288 tests, 24 ledgers verified, all workspaces typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:43:24 -04:00

1199 lines
57 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
/** Advance past round 1 (both players just end their turns). */
/** Put attacker and defender in mutual LOS (same square works for spells too). */
describe("attack spells", () => {
it("fireball does 5 flat damage when unopposed", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(state.stack).not.toBeNull();
state = must(state, defender, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
});
it("fireball destroys carried magic stones only if damage gets through", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
// Full shield stops everything -> stones survive. (A total stop
// resolves on the attacker's pass; no bounce back.)
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
});
it("lightning blast deals number-card damage and stuns", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lb = giveCard(state, attacker, "lightning-blast");
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
state = must(state, attacker, {
type: "cast", instanceId: lb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(11);
expect(d.lostTurns).toBe(1);
// The stunned player's next turn is skipped.
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(attacker); // defender was skipped
});
it("blunt halves damage rounding the result up; absorb takes 3 off", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
d.hand[1] = { instanceId: "absorb#T", cardId: "absorb" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "counteract", instanceId: "absorb#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
// 5 -> blunt -> ceil(2.5)=3 -> absorb -> 0
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
// All damage stopped -> no stones logic, no stun, attack fully spent.
});
it("anti-anti nullifies a counteraction", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
// Shield nullified: full 5 damage lands.
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
});
it("full reflection sends the whole spell back at the caster", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "full-reflection#T", cardId: "full-reflection" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
state = must(state, attacker, { type: "pass" });
// The returned spell is a fresh attack on its caster: their window.
state = must(state, attacker, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("absorb spell nullifies the attack and takes the card into hand", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "absorb-spell#T", cardId: "absorb-spell" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "absorb-spell#T" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(after.hand.some((c) => c.cardId === "fireball")).toBe(true);
expect(state.stack).toBeNull();
});
it("waterbolt splits number value between damage and knockback", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Same square: waterbolt needs a legal target; knockback is the engine's problem.
defender.position = { ...attacker.position };
const wb = giveCard(state, attacker.id, "waterbolt");
const a = state.players.find((p) => p.id === attacker.id)!;
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
const bad = applyCommand(state, attacker.id, {
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender.id },
params: { damage: 1, knockback: 1 },
});
expect(bad.ok).toBe(false); // must total 4
state = must(state, attacker.id, {
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender.id },
params: { damage: 3, knockback: 1 },
});
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
});
it("requires line of sight for LOS attacks", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Find any cell without LOS from the attacker.
const view = boardView(state);
let hidden = null;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number);
if (!hasLineOfSight(view, attacker.position, { x: x!, y: y! })) { hidden = { x: x!, y: y! }; break; }
}
expect(hidden).not.toBeNull();
defender.position = hidden!;
const fb = giveCard(state, attacker.id, "fireball");
const result = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(result.ok).toBe(false);
});
});
describe("neutral spells", () => {
it("create wall then destroy wall, with collapse damage", () => {
let { state } = newGame();
const caster = activePlayer(state);
// Find an open edge adjacent to the caster.
const view = boardView(state);
let edge: { cell: typeof caster.position; side: Side } | null = null;
for (const side of ["N", "S", "E", "W"] as Side[]) {
const n = neighbor(caster.position, side);
if (view.cells[cellKey(n)] && !(edgeKey(caster.position, side) in view.edges)) {
edge = { cell: caster.position, side };
break;
}
}
expect(edge).not.toBeNull();
const cw = giveCard(state, caster.id, "create-wall");
state = must(state, caster.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
});
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("wall");
// Destroying it hurts anyone standing beside it — the caster is adjacent.
const dw = giveCard(state, caster.id, "destroy-wall");
state = must(state, caster.id, {
type: "cast", instanceId: dw.instanceId,
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
});
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("open");
expect(state.players.find((p) => p.id === caster.id)!.life).toBe(11);
});
it("neutrals do not consume the attack and multiple can be cast", () => {
let { state } = newGame();
const caster = activePlayer(state);
const s1 = giveCard(state, caster.id, "speed", "T1");
state = must(state, caster.id, { type: "cast", instanceId: s1.instanceId });
expect(state.turn.attackUsed).toBe(false);
const s2 = giveCard(state, caster.id, "speed", "T2");
state = must(state, caster.id, { type: "cast", instanceId: s2.instanceId });
expect(state.players.find((p) => p.id === caster.id)!.extraTurns).toBe(2);
});
it("speed grants an extra turn before play passes", () => {
let { state } = newGame();
const caster = activePlayer(state);
const sp = giveCard(state, caster.id, "speed");
state = must(state, caster.id, { type: "cast", instanceId: sp.instanceId });
state = must(state, caster.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(caster.id); // extra turn, same player
state = must(state, caster.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).not.toBe(caster.id);
});
});
describe("stack discipline", () => {
it("locks other commands while an attack is unresolved", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
// Attacker cannot move while awaiting the defender.
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
// Defender cannot move either — only counteract or pass.
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
});
});
describe("speedstone", () => {
it("displaying it grants the extra movement point immediately", () => {
let { state } = newGame();
state = toRound2(state);
const who = activePlayer(state).id;
giveCard(state, who, "speedstone");
expect(state.turn.movementAllowance).toBe(3);
state = must(state, who, { type: "cast", instanceId: "speedstone#T" });
expect(state.turn.movementAllowance).toBe(4);
// And it persists into later turns via the displayed stone.
state = must(state, who, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(who);
expect(state.turn.movementAllowance).toBe(4);
});
});
describe("the post-game reveal", () => {
it("shows an eliminated player's hand as they fell, not their emptied one", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
defender.life = 1;
defender.hand = [
{ instanceId: "fireball#J", cardId: "fireball" },
{ instanceId: "number-6#J", cardId: "number-6" },
];
state = must(state, attacker.id, { type: "punch", targetId: defender.id });
state = must(state, defender.id, { type: "pass" });
expect(state.phase).toBe("finished");
// The killer took the cards — but the reveal remembers the fallen hand.
const view = viewFor(state, attacker.id);
const fallen = view.revealedHands?.[defender.id] ?? [];
expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]);
});
});
describe("stored-log compatibility", () => {
it("replays the single-number command form older logs contain", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
giveCard(state, attacker, "number-3", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, numberInstanceId: "number-3#N",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
});
});
describe("the two-player deck", () => {
it("two-player games shed LIFESAVER; bigger tables keep it", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
const inGame = (s: typeof two.state) =>
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
expect(inGame(two.state)).toBe(false);
// Three players keep it.
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"] });
expect(inGame(three.state)).toBe(true);
});
});
describe("attacking walls and doors", () => {
function wallBeside(state: GameState): { cell: Cell; side: Side } {
const me = activePlayer(state);
const view = boardView(state);
for (const side of SIDES) {
if (view.edges[edgeKey(me.position, side)] === "wall") return { cell: me.position, side };
}
throw new Error("setup: seed 42 lost its adjacent wall");
}
it("punches chip a wall for 1 and spend the turn's attack", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
const { cell, side } = wallBeside(state);
state = must(state, me.id, { type: "punchWall", cell, side });
expect(state.wallDamage[edgeKey(cell, side)]).toBe(1);
expect(state.turn.attackUsed).toBe(true);
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
});
it("repeated fireballs bring a wall down at 20 accumulated damage", () => {
let { state } = newGame();
state = toRound2(state);
let me = activePlayer(state);
const { cell, side } = wallBeside(state);
const key = edgeKey(cell, side);
// Fireball is a flat 5: four castings accumulate 5, 10, 15, then 20 fells it.
for (let round = 0; round < 4; round++) {
me = activePlayer(state);
const fb = giveCard(state, me.id, "fireball", `F${round}`, 0);
state = must(state, me.id, {
type: "cast", instanceId: fb.instanceId,
target: { kind: "edge", cell, side },
});
if (round < 3) {
expect(state.wallDamage[key]).toBe(5 * (round + 1));
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
}
expect(state.wallDamage[key]).toBeUndefined();
expect(boardView(state).edges[key]).toBe("open");
// The way is open: walk through where the wall stood.
state = must(state, me.id, { type: "move", direction: side });
expect(cellKey(activePlayer(state).position)).toBe(cellKey(neighbor(cell, side)));
});
it("open corridors and firewalls are not punchable", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
const view = boardView(state);
const openSide = SIDES.find((s) => (view.edges[edgeKey(me.position, s)] ?? "open") === "open")!;
const r = applyCommand(state, me.id, { type: "punchWall", cell: me.position, side: openSide });
expect(r.ok).toBe(false);
});
});
describe("the ward window and chaos shields", () => {
function threeGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
}
it("the grab hangs while the ward's owner decides — and arming ahead is refused", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
expect(applyCommand(state, owner.id, { type: "armWard" }).ok).toBe(false);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" });
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
// Spring it: the thief bleeds 3 and the ward is spent.
state = must(state, owner.id, { type: "wardChoice", play: true });
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
const shielder = others[0]!;
giveCard(state, shielder, "full-shield", "S", 0);
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
// No stack: the queue opens at once, in turn order from the caster.
expect(state.stack).toBeNull();
expect(state.chaosPending?.queue.length).toBe(2);
const first = state.chaosPending!.queue[0]!;
state = first === shielder
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
: must(state, first, { type: "pass" });
const second = state.chaosPending!.queue[0]!;
state = second === shielder
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
: must(state, second, { type: "pass" });
expect(state.chaosPending).toBeNull();
// The shielded hand rode out the storm untouched.
expect(shielderHand()).toEqual(kept);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const defender = state.players.find((p) => p.id !== caster.id)!;
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
giveCard(state, defender.id, "full-reflection", "R", 0);
giveCard(state, bystander.id, "full-shield", "S", 0);
const bystanderHand = bystander.hand.map((c) => c.instanceId).sort();
state = must(state, caster.id, {
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
});
// "REFLECTIONS have no effect."
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "full-reflection#R" }).ok).toBe(false);
state = must(state, defender.id, { type: "pass" });
// Now the bystander's window: they shield out and keep their hand.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "counteract", instanceId: "full-shield#S" });
expect(state.chaosPending).toBeNull();
const after = state.players.find((p) => p.id === bystander.id)!;
expect(after.hand.map((c) => c.instanceId).sort()).toEqual(
bystanderHand.filter((id) => id !== "full-shield#S").sort());
});
it("chaos: the defender's full shield sits them out without stopping it", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const defender = state.players.find((p) => p.id !== caster.id)!;
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
giveCard(state, defender.id, "full-shield", "S", 0);
const defenderHand = () => state.players.find((p) => p.id === defender.id)!.hand.map((c) => c.instanceId).sort();
const kept = defenderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, {
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
// Bystander declines; the scramble happens without the defender.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "pass" });
expect(state.chaosPending).toBeNull();
expect(defenderHand()).toEqual(kept);
});
});
describe("zero-damage utility attacks", () => {
it("an uncountered DROP OBJECT is not reported as stopped — and the treasure falls", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const treasure = state.treasures.find((t) => t.owner !== defender)!;
treasure.carriedBy = defender;
treasure.position = null;
d.carriedTreasureId = treasure.id;
const dob = giveCard(state, attacker, "drop-object");
state = must(state, attacker, {
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
target: { kind: "player", playerId: defender },
});
const result = applyCommand(state, defender, { type: "pass" });
if (!result.ok) throw new Error(result.error);
const resolved = result.events.find((e) => e.type === "attackResolved");
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(false);
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBeNull();
});
it("FULL SHIELD genuinely stops it — the treasure stays carried", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const treasure = state.treasures.find((t) => t.owner !== defender)!;
treasure.carriedBy = defender;
treasure.position = null;
d.carriedTreasureId = treasure.id;
const dob = giveCard(state, attacker, "drop-object");
giveCard(state, defender, "full-shield", "FS", 0);
state = must(state, attacker, {
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
const result = applyCommand(state, attacker, { type: "pass" });
if (!result.ok) throw new Error(result.error);
const resolved = result.events.find((e) => e.type === "attackResolved");
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
});
});
describe("the VISIONSTONE pierces one wall", () => {
function stoneRig() {
let { state } = newGame();
state = toRound2(state);
const a = activePlayer(state);
const b = state.players.find((p) => p.id !== a.id)!;
a.position = { x: 2, y: 4 };
b.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall";
return { state, a, b };
}
it("its bearer casts an attack straight through the wall", () => {
const { state, a, b } = stoneRig();
const fb = giveCard(state, a.id, "fireball", "FB", 1);
const refused = applyCommand(state, a.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
});
expect(refused.ok).toBe(false);
const stone = giveCard(state, a.id, "visionstone", "VS");
a.displayed.push(stone.instanceId);
const cast = applyCommand(state, a.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
});
expect(cast.ok).toBe(true);
});
it("its bearer plants a THORNBUSH through the wall", () => {
const { state, a } = stoneRig();
// The square past the wall must be bare ground for a creation.
const cell = { x: 2, y: 5 };
for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 };
const bush = giveCard(state, a.id, "thornbush", "TB");
const refused = applyCommand(state, a.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("line of sight");
const stone = giveCard(state, a.id, "visionstone", "VS", 1);
a.displayed.push(stone.instanceId);
const cast = applyCommand(state, a.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
});
expect(cast.ok).toBe(true);
});
it("the client's sighted squares agree with the stone", () => {
const { state, a, b } = stoneRig();
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);
const stone = giveCard(state, a.id, "visionstone", "VS");
a.displayed.push(stone.instanceId);
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true);
});
});
describe("teleport wraps the maze's rim", () => {
it("four spaces counted through a warp mouth reach the far side", () => {
let { state } = newGame();
state = toRound2(state);
// Stand the active wizard at a wraparound mouth; the paired cell is
// ONE teleport step away, exactly as it is one walking step.
const active = activePlayer(state);
const warp = boardView(state).warps[0]!;
active.position = { ...warp.from.cell };
const tp = giveCard(state, active.id, "teleport", "TP", 0);
const r = applyCommand(state, active.id, {
type: "cast", instanceId: tp.instanceId,
target: { kind: "cell", cell: { ...warp.to.cell } },
});
expect(r.ok).toBe(true);
if (r.ok) {
const after = r.state.players.find((p) => p.id === active.id)!;
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
}
});
});
describe("teleport as a counteraction", () => {
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
const escape = { x: d.position.x, y: d.position.y + 1 };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
state = must(state, attacker, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(cellKey(after.position)).toBe(cellKey(escape));
});
it("reaches at most four spaces", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
const far = { x: d.position.x, y: d.position.y + 5 };
expect(applyCommand(state, defender, {
type: "counteract", instanceId: tp.instanceId, params: { cell: far },
}).ok).toBe(false);
});
});
describe("slime holds spells", () => {
function slimeRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
// A slime square beside the victim, with the victim adjacent to walk in.
const spot = { x: victim.position.x, y: victim.position.y };
const cellBeside = (() => {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, spot, side);
if (t.kind === "step" && !state.players.some((p) => cellKey(p.position) === cellKey(t.to)) &&
!boardView(state).homes.some((h) => cellKey(h) === cellKey(t.to))) {
return { cell: t.to, side };
}
}
throw new Error("setup: no free square beside the victim");
})();
state.squareContents[cellKey(cellBeside.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
// The caster stands with the victim: one open edge to the slime = clear LOS.
caster.position = { ...victim.position };
return { state, caster: caster.id, victim: victim.id, slimeAt: cellBeside };
}
it("a fireball cast at the slime waits, then goes off on the next visitor", () => {
const rig = slimeRig();
let state = rig.state;
const fb = giveCard(state, rig.caster, "fireball");
state = must(state, rig.caster, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
});
expect(Object.keys(state.slimeTraps).length).toBe(1);
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
// The victim walks in: stuck, and the fireball erupts.
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
expect(state.stack?.attackCard?.cardId).toBe("fireball");
expect(state.stack?.trapped).toBe(true);
state = must(state, rig.victim, { type: "pass" });
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(10);
expect(Object.keys(state.slimeTraps).length).toBe(0);
});
it("reflections off a trapped spell never touch its caster", () => {
const rig = slimeRig();
let state = rig.state;
const fb = giveCard(state, rig.caster, "fireball");
giveCard(state, rig.victim, "full-reflection", "FR", 0);
state = must(state, rig.caster, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
});
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, rig.caster, { type: "pass" });
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
});
it("a five-point waterbolt washes the slime (and its cargo) away", () => {
const rig = slimeRig();
let state = rig.state;
const fb = giveCard(state, rig.caster, "fireball");
state = must(state, rig.caster, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
});
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
const wb = giveCard(state, rig.victim, "waterbolt", "WB", 0);
giveCard(state, rig.victim, "number-5", "N", 1);
state = must(state, rig.victim, {
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-5#N"],
target: { kind: "cell", cell: rig.slimeAt.cell },
});
expect(state.squareContents[cellKey(rig.slimeAt.cell)]).toBeUndefined();
expect(Object.keys(state.slimeTraps).length).toBe(0);
});
});
describe("answering counteractions (FAQ rulings)", () => {
function freshGame() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
}
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
let { state } = freshGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "counteract", instanceId: ab.instanceId });
state = must(state, defender, { type: "pass" });
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
expect(a.hand.some((c) => c.instanceId === "full-reflection#FR")).toBe(true);
expect(a.life).toBe(15); // nothing reflected
expect(d.life).toBe(10); // the fireball lands after all
});
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
let { state } = freshGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
giveCard(state, defender, "full-shield", "FS", 0);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
const r = applyCommand(state, attacker, { type: "counteract", instanceId: ab.instanceId });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
});
it("ANTI-ANTI does not work against a teleport escape", () => {
let { state } = freshGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
const escape = { x: d.position.x, y: d.position.y + 1 };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
expect(r.ok).toBe(false);
state = must(state, attacker, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(cellKey(after.position)).toBe(cellKey(escape));
});
});
describe("speed and warp-token creation", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
state.sustained.push({
id: "fx-blind", cardId: "blind", casterId: other.id, targetId: me.id,
remainingTurns: 2, data: {},
});
giveCard(state, me.id, "speed", "SP", 0);
state = must(state, me.id, { type: "cast", instanceId: "speed#SP" });
state = must(state, me.id, { type: "endTurn", draw: 0 });
// The bonus turn began: one duration turn burned, recipient-counted.
expect(activePlayer(state).id).toBe(me.id);
expect(state.sustained.find((s) => s.id === "fx-blind")!.remainingTurns).toBe(1);
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(other.id);
});
it("nothing can be created on a dimensional warp token", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const me = activePlayer(state);
const spot = { x: me.position.x + 1, y: me.position.y };
state.dimWarps.push({ a: spot, b: { x: 0, y: 0 } });
const tb = giveCard(state, me.id, "thornbush", "T", 0);
const r = applyCommand(state, me.id, {
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/warp shimmers/);
});
});
describe("redirection", () => {
it("the two chosen exits connect; their old partners pair with each other", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const me = activePlayer(state);
// Pick two exits that are NOT already partners.
const warps = state.board.warps;
const wa = warps[0]!;
const wb = warps.find((w) => cellKey(w.from.cell) !== cellKey(wa.from.cell) &&
cellKey(w.from.cell) !== cellKey(wa.to.cell))!;
const paCell = { ...wa.to.cell };
const pbCell = { ...wb.to.cell };
const rd = giveCard(state, me.id, "redirection", "RD", 0);
state = must(state, me.id, {
type: "cast", instanceId: rd.instanceId,
target: { kind: "cell", cell: wb.from.cell }, params: { cell: wa.from.cell },
});
const after = (from: Cell) => state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(from))!;
expect(cellKey(after(wa.from.cell).to.cell)).toBe(cellKey(wb.from.cell));
expect(cellKey(after(wb.from.cell).to.cell)).toBe(cellKey(wa.from.cell));
expect(cellKey(after(paCell).to.cell)).toBe(cellKey(pbCell));
expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell));
});
});
describe("total stops end the exchange", () => {
function faceOffRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
return { state, attacker, defender };
}
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
const lifeBefore = d.life;
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" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
});
it("a partial counter still invites the defender to stack more", () => {
let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).not.toBeNull();
expect(state.stack!.waitingOn).toBe(defender);
});
it("a nullified shield is no stop — the exchange returns to the defender", () => {
let { state, attacker, defender } = faceOffRig();
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15);
});
});
describe("escapes and elemental walls as counteractions", () => {
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const atk = giveCard(state, attacker, attackId);
const d = state.players.find((p) => p.id === defender)!;
defenderCards.forEach((c, i) => { d.hand[i] = c; });
state = must(state, attacker, {
type: "cast", instanceId: atk.instanceId, target: { kind: "player", playerId: defender },
});
return { state, attacker, defender, atk };
}
it("invisible vanishes mid-stack: the sustained goes up, the hit needs a 1", () => {
let { state, defender } = rigged("fireball", [
{ instanceId: "invisible#T", cardId: "invisible" },
{ instanceId: "number-3#T", cardId: "number-3" },
]);
state = must(state, defender, {
type: "counteract", instanceId: "invisible#T", numberInstanceIds: ["number-3#T"],
});
const fx = state.sustained.find((s) => s.cardId === "invisible" && s.targetId === defender);
expect(fx).toBeDefined();
expect(fx!.remainingTurns).toBe(3);
// The attached number left the hand with the spell.
const d = state.players.find((p) => p.id === defender)!;
expect(d.hand.some((c) => c.instanceId === "number-3#T")).toBe(false);
});
it("anti-anti cannot pin the vanishing — escape is escape", () => {
let { state, attacker, defender } = rigged("fireball", [
{ instanceId: "invisible#T", cardId: "invisible" },
]);
state = must(state, defender, { type: "counteract", instanceId: "invisible#T" });
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
const refused = applyCommand(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
expect(refused.ok).toBe(false);
});
it("waterwall meets the fireball and stops it whole", () => {
let { state, attacker, defender } = rigged("fireball", [
{ instanceId: "waterwall#T", cardId: "waterwall" },
]);
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
state = must(state, attacker, { type: "pass" });
// A total stop: the attacker's declined answer resolves at once.
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
});
it("waterwall refuses everything that is not a fireball", () => {
let { state, defender } = rigged("dagger", [
{ instanceId: "waterwall#T", cardId: "waterwall" },
]);
const refused = applyCommand(state, defender, { type: "counteract", instanceId: "waterwall#T" });
expect(refused.ok).toBe(false);
});
});
describe("empathy as a counteraction", () => {
function empathyRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
d.hand[1] = { instanceId: "number-3#T", cardId: "number-3" };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
return { state, attacker, defender };
}
it("the blow lands on both — and the link lingers its number's turns", () => {
let { state, attacker, defender } = empathyRig();
state = must(state, defender, {
type: "counteract", instanceId: "empathy#T", numberInstanceIds: ["number-3#T"],
});
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
const fx = state.sustained.find((f) => f.cardId === "empathy" && f.targetId === defender);
expect(fx?.remainingTurns).toBe(3);
});
it("anti-anti severs the link: no mirror, no lingering spell", () => {
let { state, attacker, defender } = empathyRig();
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false);
});
});
describe("empathy mirrors resolution-hook blows", () => {
it("a believed illusion bites both wizards", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ill = giveCard(state, attacker, "illusionary-attack");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
state = must(state, attacker, {
type: "cast", instanceId: ill.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "fireball" },
});
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const dAfter = state.players.find((p) => p.id === defender)!;
const aAfter = state.players.find((p) => p.id === attacker)!;
// Whichever way belief rolled, the two lives moved in lockstep.
expect(15 - aAfter.life).toBe(15 - dAfter.life);
});
});
describe("the reflected-attack window", () => {
function reflectRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
// The attacker's pass meets the total stop and settles the reflection —
// which opens the returned spell's window, waiting on the caster.
state = must(state, attacker, { type: "pass" });
return { state, attacker, defender };
}
it("the returned spell opens a window: absorb soaks the reflected blast", () => {
let { state, attacker, defender } = reflectRig();
// The reflection settled into a NEW stack: the caster now defends.
expect(state.stack).not.toBeNull();
expect(state.stack!.defenderId).toBe(attacker);
expect(state.stack!.reflectedBase).toEqual({ damage: 10, duration: 0 });
giveCard(state, attacker, "absorb", "AB", 0);
state = must(state, attacker, { type: "counteract", instanceId: "absorb#AB" });
state = must(state, defender, { type: "pass" });
state = must(state, attacker, { type: "pass" });
// 10 reflected minus absorb's 3.
expect(state.players.find((p) => p.id === attacker)!.life).toBe(8);
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
});
it("a second full reflection turns it around again — the ping-pong war", () => {
let { state, attacker, defender } = reflectRig();
giveCard(state, attacker, "full-reflection", "FR2", 0);
state = must(state, attacker, { type: "counteract", instanceId: "full-reflection#FR2" });
state = must(state, defender, { type: "pass" });
// Turned around again: now the original defender must answer it.
expect(state.stack).not.toBeNull();
expect(state.stack!.defenderId).toBe(defender);
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
});
});
describe("a reflected lightning blast ends the caster's turn", () => {
function boltRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lb = giveCard(state, attacker, "lightning-blast");
giveCard(state, attacker, "number-3", "N", 1);
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "pass" });
// rev 25+: the returned bolt opens its window; the caster declines to answer.
if (state.stack) state = must(state, attacker, { type: "pass" });
return { state, attacker, defender };
}
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
let { state, attacker } = boltRig();
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
expect(state.turn.actionsEnded).toBe(true);
// No more marching, and the end-of-turn draw comes up empty.
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
const before = state.players.find((p) => p.id === attacker)!.hand.length;
state = must(state, attacker, { type: "endTurn", draw: 2 });
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(before);
// And the stun still costs the next turn.
expect(state.players.find((p) => p.id === attacker)!.lostTurns
+ (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1);
});
});
describe("fireball burns only the stones in play", () => {
it("a displayed stone dies; a hidden one stays secret and safe", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "speedstone#T", cardId: "speedstone" };
d.displayed = ["powerstone#T"]; // only the powerstone is on the table
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.hand.some((c) => c.instanceId === "powerstone#T")).toBe(false);
expect(after.hand.some((c) => c.instanceId === "speedstone#T")).toBe(true);
});
});
describe("stone dead counts only the stones in play", () => {
it("hidden stones neither add damage nor betray their count", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "stone-dead");
giveCard(state, attacker, "number-3", "N", 1);
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "speedstone#T", cardId: "speedstone" };
d.hand[2] = { instanceId: "bloodstone#T", cardId: "bloodstone" };
d.displayed = ["powerstone#T"]; // one on the table, two secret
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
// 3 x 1 displayed stone = 3; the bloodstone is hidden, so it soaks
// nothing: 15 - 3 = 12.
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
});
});
describe("MENTAL FORCE respects the victim's three walked spaces", () => {
it("a destination beyond the walls is refused up front, card kept", () => {
let { state } = newGame();
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
caster.position = { x: 0, y: 0 };
victim.position = { x: 2, y: 4 };
// Seal the victim into their square: every walked space is out of
// reach, so ANY other destination is beyond three moved spaces.
for (const side of SIDES) {
state.edgeOverrides[edgeKey(victim.position, side)] = "wall";
}
const mf = giveCard(state, caster.id, "mental-force", "MF");
const far = { x: 4, y: 9 };
const refused = applyCommand(state, caster.id, {
type: "cast", instanceId: mf.instanceId,
target: { kind: "player", playerId: victim.id }, params: { cell: far },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("three moved spaces");
// The card is still in hand — nothing was spent on the refusal.
expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true);
});
});
describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
it("a bentCorner stack yields two auditable legs through a midpoint", () => {
let { state } = newGame();
state = toRound2(state);
const a = activePlayer(state);
const d = state.players.find((p) => p.id !== a.id)!;
// Diagonal neighbors: center-to-center sight grazes the shared corner
// (blocked, strict reading), but the legs through (3,2) are opened.
a.position = { x: 2, y: 2 };
d.position = { x: 3, y: 3 };
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open";
state.stack = {
attackerId: a.id, defenderId: d.id,
attackCard: { instanceId: "fireball#T", cardId: "fireball" },
numberValue: null, amplifyFactor: 1, extendFactor: 1,
powerAttackPoints: 0, params: null, kind: "spell",
counters: [], waitingOn: d.id, bentCorner: true,
};
// Wall the straight diagonal shut so only the bend can answer.
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall";
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall";
const traced = stackSightTrace(viewFor(state, d.id));
expect(traced).not.toBeNull();
// The engine promises A workable corner, not which one.
expect(traced?.bend).toBeDefined();
expect(traced?.bend?.mid).toBeDefined();
});
});