Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1037 lines
50 KiB
TypeScript
1037 lines
50 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. (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: 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("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);
|
|
});
|
|
});
|