Scoped to everything since the last pass (695307d). The residue of
fast iteration, removed: a reduced-motion media query that had
swallowed a full copy of the .faq-seal rules; doc comments orphaned
from their functions by inserted methods; the FAQ scrape's seams
(section headings run into ruling bodies under the wrong topics, a
next-page heading shipped as a ruling, an amputated "h", and rulings
filed under alphabetically-nearest strangers — Large Rock/Dagger now
lives on those cards; Book of Spells and Torquemada describe no card
in this set and are gone); the write-only aisle flag left behind by
the reverted rev 7; a linter-silenced dead destructure; a duplicated
median lookup; dead casts; and the fallback path that ignored the
apprentice's one-card draw.
Tests now typecheck (tsconfig includes test/), which surfaced the
missing type imports and a drifted creature literal hiding under an
as-cast. Also: a no-op self-assignment, mid-file imports hoisted, a
dynamic-import habit made static, a hedge comment replaced with a
loud setup failure, the fallback-retries-itself dead rung removed
from both bot drivers, and the twin peek scrims merged.
Deliberately kept: the TIERS lookup guard (ledger JSON is untrusted),
the wand-cost stanzas (they differ on the power-attack trade — a
rules question, not a dedup), and rollDie's coexistence with rollD4
(migrating changes visible logs; future work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
847 lines
40 KiB
TypeScript
847 lines
40 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 { 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.
|
|
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", () => {
|
|
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("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("ward arming and chaos shields (rules rev 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.
|
|
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("speed and warp-token creation (rules rev 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));
|
|
});
|
|
});
|
|
|
|
describe("total stops end the exchange (rules rev 10)", () => {
|
|
function rev10Game() {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
|
|
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 } = rev10Game();
|
|
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 } = rev10Game();
|
|
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 } = rev10Game();
|
|
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);
|
|
});
|
|
});
|