Files
wizwar6e/packages/engine/test/casting.test.ts
T
Eric WagonerandClaude Fable 5 2c3e622ce5 Rev 9: REDIRECTION connects the exits you choose
Playtesting read the card right and the engine had it wrong. "Placing
the A tokens on one set of exits & the B tokens on the other" means
the two exits you mark BECOME a pair — and their former partners pair
with each other — not that the two exits trade destinations. Under
rev 9, click the exit and then the exit you want it to connect TO
(the hint now says so); already-connected exits are refused. Earlier
games keep the swap their logs were validated against. Test pins the
full four-way rewiring.

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

797 lines
38 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
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.
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { 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" });
state = must(state, defender, { 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);
});
it("unimplemented cards refuse to cast with a clear error", () => {
let { state } = newGame();
const caster = activePlayer(state);
const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not implemented/);
});
});
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", async () => {
const { viewFor } = await import("../src/view");
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("deck revisions", () => {
it("revision 2 removes LIFESAVER at two players, and only there", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
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; old two-player games (no deckRev) keep it too.
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
expect(inGame(three.state)).toBe(true);
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
expect(inGame(legacy.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("a powered fireball brings 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("rules revision 3", () => {
function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
}
it("ward springs only when its owner armed it", () => {
let { state } = rev3Game();
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);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
// Unarmed: the grab goes unpunished.
thief.position = { ...treasure.position! };
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3.
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
const ownerTurnState = (() => {
let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
return s;
})();
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(s3.wardArmed).not.toContain(owner.id);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = rev3Game();
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 } = rev3Game();
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
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
// 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);
});
it("legacy games (rev < 3) keep the automatic ward", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" });
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
});
});
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" });
state = must(state, attacker, { type: "pass" });
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(true);
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
});
});
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" });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(cellKey(after.position)).toBe(cellKey(escape));
});
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
let { state } = newGame();
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 home = { ...d.position };
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: { x: home.x, y: home.y + 1 } } });
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(cellKey(after.position)).toBe(cellKey(home));
expect(after.life).toBe(10);
});
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"], deckRev: 5 });
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" });
state = must(state, rig.victim, { 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 rev6() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
}
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
let { state } = rev6();
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 } = rev6();
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 (rev 6)", () => {
let { state } = rev6();
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" });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(cellKey(after.position)).toBe(cellKey(escape));
});
});
describe("rules revision 8", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
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"], deckRev: 8 });
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 (rules rev 9)", () => {
it("the two chosen exits connect; their old partners pair with each other", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
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));
});
});