"It's not letting me cast Absorb Spell on your Full Shield" — and Jolly's FAQ says exactly why: "ABSORB SPELL will work on FULL REFLECTION or REFLECTION, but not FULL SHIELD, as that is not used against you, but cast upon the player using it." The refusal stands, now with that reason in the error — and the neighboring case the engine wrongly refused is fixed: an attacker may answer a reflection with ABSORB SPELL, nullifying it and taking the reflection card into their hand, with the original spell then landing unturned. And the card face corrects my own hours-old work: ANTI-ANTI "does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE" — so under rules rev 6 it can no longer pin a teleport escape. Earlier games keep their stored chains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
737 lines
35 KiB
TypeScript
737 lines
35 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
|
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
|
|
|
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
|
/** Advance past round 1 (both players just end their turns). */
|
|
/** Put attacker and defender in mutual LOS (same square works for spells too). */
|
|
describe("attack spells", () => {
|
|
it("fireball does 5 flat damage when unopposed", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
expect(state.stack).not.toBeNull();
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.stack).toBeNull();
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
|
});
|
|
|
|
it("fireball destroys carried magic stones only if damage gets through", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
|
|
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
// Full shield stops everything -> stones survive.
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(after.life).toBe(15);
|
|
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
|
|
});
|
|
|
|
it("lightning blast deals number-card damage and stuns", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const lb = giveCard(state, attacker, "lightning-blast");
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
|
|
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: lb.instanceId, numberInstanceId: "number-4#T",
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.life).toBe(11);
|
|
expect(d.lostTurns).toBe(1);
|
|
|
|
// The stunned player's next turn is skipped.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(attacker); // defender was skipped
|
|
});
|
|
|
|
it("blunt halves damage rounding the result up; absorb takes 3 off", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
|
d.hand[1] = { instanceId: "absorb#T", cardId: "absorb" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "counteract", instanceId: "absorb#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
// 5 -> blunt -> ceil(2.5)=3 -> absorb -> 0
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
|
// All damage stopped -> no stones logic, no stun, attack fully spent.
|
|
});
|
|
|
|
it("anti-anti nullifies a counteraction", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
|
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
|
|
state = must(state, defender, { type: "pass" });
|
|
// Shield nullified: full 5 damage lands.
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
|
});
|
|
|
|
it("full reflection sends the whole spell back at the caster", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "full-reflection#T", cardId: "full-reflection" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
|
});
|
|
|
|
it("absorb spell nullifies the attack and takes the card into hand", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "absorb-spell#T", cardId: "absorb-spell" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "absorb-spell#T" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(after.life).toBe(15);
|
|
expect(after.hand.some((c) => c.cardId === "fireball")).toBe(true);
|
|
expect(state.stack).toBeNull();
|
|
});
|
|
|
|
it("waterbolt splits number value between damage and knockback", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
// Same square: waterbolt needs a legal target; knockback is the engine's problem.
|
|
defender.position = { ...attacker.position };
|
|
const wb = giveCard(state, attacker.id, "waterbolt");
|
|
const a = state.players.find((p) => p.id === attacker.id)!;
|
|
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
|
|
|
|
const bad = applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
|
|
target: { kind: "player", playerId: defender.id },
|
|
params: { damage: 1, knockback: 1 },
|
|
});
|
|
expect(bad.ok).toBe(false); // must total 4
|
|
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
|
|
target: { kind: "player", playerId: defender.id },
|
|
params: { damage: 3, knockback: 1 },
|
|
});
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
|
|
});
|
|
|
|
it("requires line of sight for LOS attacks", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
// Find any cell without LOS from the attacker.
|
|
const view = boardView(state);
|
|
let hidden = null;
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number);
|
|
if (!hasLineOfSight(view, attacker.position, { x: x!, y: y! })) { hidden = { x: x!, y: y! }; break; }
|
|
}
|
|
expect(hidden).not.toBeNull();
|
|
defender.position = hidden!;
|
|
const fb = giveCard(state, attacker.id, "fireball");
|
|
const result = applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("neutral spells", () => {
|
|
it("create wall then destroy wall, with collapse damage", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
// Find an open edge adjacent to the caster.
|
|
const view = boardView(state);
|
|
let edge: { cell: typeof caster.position; side: Side } | null = null;
|
|
for (const side of ["N", "S", "E", "W"] as Side[]) {
|
|
const n = neighbor(caster.position, side);
|
|
if (view.cells[cellKey(n)] && !(edgeKey(caster.position, side) in view.edges)) {
|
|
edge = { cell: caster.position, side };
|
|
break;
|
|
}
|
|
}
|
|
expect(edge).not.toBeNull();
|
|
|
|
const cw = giveCard(state, caster.id, "create-wall");
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: cw.instanceId,
|
|
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
|
|
});
|
|
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("wall");
|
|
|
|
// Destroying it hurts anyone standing beside it — the caster is adjacent.
|
|
const dw = giveCard(state, caster.id, "destroy-wall");
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: dw.instanceId,
|
|
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
|
|
});
|
|
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("open");
|
|
expect(state.players.find((p) => p.id === caster.id)!.life).toBe(11);
|
|
});
|
|
|
|
it("neutrals do not consume the attack and multiple can be cast", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
const s1 = giveCard(state, caster.id, "speed", "T1");
|
|
state = must(state, caster.id, { type: "cast", instanceId: s1.instanceId });
|
|
expect(state.turn.attackUsed).toBe(false);
|
|
const s2 = giveCard(state, caster.id, "speed", "T2");
|
|
state = must(state, caster.id, { type: "cast", instanceId: s2.instanceId });
|
|
expect(state.players.find((p) => p.id === caster.id)!.extraTurns).toBe(2);
|
|
});
|
|
|
|
it("speed grants an extra turn before play passes", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
const sp = giveCard(state, caster.id, "speed");
|
|
state = must(state, caster.id, { type: "cast", instanceId: sp.instanceId });
|
|
state = must(state, caster.id, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(caster.id); // extra turn, same player
|
|
state = must(state, caster.id, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).not.toBe(caster.id);
|
|
});
|
|
});
|
|
|
|
describe("stack discipline", () => {
|
|
it("locks other commands while an attack is unresolved", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
// Attacker cannot move while awaiting the defender.
|
|
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
|
|
// Defender cannot move either — only counteract or pass.
|
|
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
|
});
|
|
|
|
it("unimplemented cards refuse to cast with a clear error", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented
|
|
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
|
});
|
|
});
|
|
|
|
describe("speedstone", () => {
|
|
it("displaying it grants the extra movement point immediately", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const who = activePlayer(state).id;
|
|
giveCard(state, who, "speedstone");
|
|
expect(state.turn.movementAllowance).toBe(3);
|
|
state = must(state, who, { type: "cast", instanceId: "speedstone#T" });
|
|
expect(state.turn.movementAllowance).toBe(4);
|
|
// And it persists into later turns via the displayed stone.
|
|
state = must(state, who, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(who);
|
|
expect(state.turn.movementAllowance).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("the post-game reveal", () => {
|
|
it("shows an eliminated player's hand as they fell, not their emptied one", async () => {
|
|
const { viewFor } = await import("../src/view");
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
defender.life = 1;
|
|
defender.hand = [
|
|
{ instanceId: "fireball#J", cardId: "fireball" },
|
|
{ instanceId: "number-6#J", cardId: "number-6" },
|
|
];
|
|
state = must(state, attacker.id, { type: "punch", targetId: defender.id });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.phase).toBe("finished");
|
|
// The killer took the cards — but the reveal remembers the fallen hand.
|
|
const view = viewFor(state, attacker.id);
|
|
const fallen = view.revealedHands?.[defender.id] ?? [];
|
|
expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]);
|
|
});
|
|
});
|
|
|
|
describe("stored-log compatibility", () => {
|
|
it("replays the single-number command form older logs contain", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
giveCard(state, attacker, "number-3", "N", 1);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, numberInstanceId: "number-3#N",
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
|
|
});
|
|
});
|
|
|
|
describe("deck revisions", () => {
|
|
it("revision 2 removes LIFESAVER at two players, and only there", () => {
|
|
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
|
const inGame = (s: typeof two.state) =>
|
|
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
|
|
expect(inGame(two.state)).toBe(false);
|
|
// Three players keep it; old two-player games (no deckRev) keep it too.
|
|
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
|
expect(inGame(three.state)).toBe(true);
|
|
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
|
expect(inGame(legacy.state)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("attacking walls and doors", () => {
|
|
function wallBeside(state: GameState): { cell: Cell; side: Side } {
|
|
const me = activePlayer(state);
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
if (view.edges[edgeKey(me.position, side)] === "wall") return { cell: me.position, side };
|
|
}
|
|
throw new Error("setup: seed 42 lost its adjacent wall");
|
|
}
|
|
|
|
it("punches chip a wall for 1 and spend the turn's attack", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state);
|
|
const { cell, side } = wallBeside(state);
|
|
state = must(state, me.id, { type: "punchWall", cell, side });
|
|
expect(state.wallDamage[edgeKey(cell, side)]).toBe(1);
|
|
expect(state.turn.attackUsed).toBe(true);
|
|
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
|
|
});
|
|
|
|
it("a powered fireball brings a wall down at 20 accumulated damage", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
let me = activePlayer(state);
|
|
const { cell, side } = wallBeside(state);
|
|
const key = edgeKey(cell, side);
|
|
// Fireball is a flat 5: four castings accumulate 5, 10, 15, then 20 fells it.
|
|
for (let round = 0; round < 4; round++) {
|
|
me = activePlayer(state);
|
|
const fb = giveCard(state, me.id, "fireball", `F${round}`, 0);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: fb.instanceId,
|
|
target: { kind: "edge", cell, side },
|
|
});
|
|
if (round < 3) {
|
|
expect(state.wallDamage[key]).toBe(5 * (round + 1));
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
}
|
|
}
|
|
expect(state.wallDamage[key]).toBeUndefined();
|
|
expect(boardView(state).edges[key]).toBe("open");
|
|
// The way is open: walk through where the wall stood.
|
|
state = must(state, me.id, { type: "move", direction: side });
|
|
expect(cellKey(activePlayer(state).position)).toBe(cellKey(neighbor(cell, side)));
|
|
});
|
|
|
|
it("open corridors and firewalls are not punchable", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state);
|
|
const view = boardView(state);
|
|
const openSide = SIDES.find((s) => (view.edges[edgeKey(me.position, s)] ?? "open") === "open")!;
|
|
const r = applyCommand(state, me.id, { type: "punchWall", cell: me.position, side: openSide });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("rules revision 3", () => {
|
|
function rev3Game(seed = 42) {
|
|
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
|
|
}
|
|
|
|
it("ward springs only when its owner armed it", () => {
|
|
let { state } = rev3Game();
|
|
state = toRound2(state);
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
const thief = activePlayer(state);
|
|
const owner = state.players.find((p) => p.id !== thief.id)!;
|
|
giveCard(state, owner.id, "ward", "W", 0);
|
|
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
|
|
|
// Unarmed: the grab goes unpunished.
|
|
thief.position = { ...treasure.position! };
|
|
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
|
|
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
|
|
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
|
|
|
|
// Armed (on the owner's own turn): the trap bites for 3.
|
|
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
|
|
const ownerTurnState = (() => {
|
|
let s = state;
|
|
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
|
|
return s;
|
|
})();
|
|
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
|
|
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
|
|
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
|
|
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
|
|
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
|
expect(s3.wardArmed).not.toContain(owner.id);
|
|
});
|
|
|
|
it("chaos: bystanders may shield out, reflections are refused", () => {
|
|
let { state } = rev3Game();
|
|
state = toRound2(state);
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
const caster = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== caster.id)!;
|
|
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
|
|
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
|
giveCard(state, defender.id, "full-reflection", "R", 0);
|
|
giveCard(state, bystander.id, "full-shield", "S", 0);
|
|
const bystanderHand = bystander.hand.map((c) => c.instanceId).sort();
|
|
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
|
|
});
|
|
// "REFLECTIONS have no effect."
|
|
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "full-reflection#R" }).ok).toBe(false);
|
|
state = must(state, defender.id, { type: "pass" });
|
|
// Now the bystander's window: they shield out and keep their hand.
|
|
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
|
state = must(state, bystander.id, { type: "counteract", instanceId: "full-shield#S" });
|
|
expect(state.chaosPending).toBeNull();
|
|
const after = state.players.find((p) => p.id === bystander.id)!;
|
|
expect(after.hand.map((c) => c.instanceId).sort()).toEqual(
|
|
bystanderHand.filter((id) => id !== "full-shield#S").sort());
|
|
});
|
|
|
|
it("chaos: the defender's full shield sits them out without stopping it", () => {
|
|
let { state } = rev3Game();
|
|
state = toRound2(state);
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
const caster = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== caster.id)!;
|
|
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
|
|
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
|
giveCard(state, defender.id, "full-shield", "S", 0);
|
|
const defenderHand = () => state.players.find((p) => p.id === defender.id)!.hand.map((c) => c.instanceId).sort();
|
|
const kept = defenderHand().filter((id) => id !== "full-shield#S").sort();
|
|
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
|
|
});
|
|
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
|
|
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
|
|
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
|
|
// Bystander declines; the scramble happens without the defender.
|
|
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
|
state = must(state, bystander.id, { type: "pass" });
|
|
expect(state.chaosPending).toBeNull();
|
|
expect(defenderHand()).toEqual(kept);
|
|
});
|
|
|
|
it("legacy games (rev < 3) keep the automatic ward", () => {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
|
state = toRound2(state);
|
|
const thief = activePlayer(state);
|
|
const owner = state.players.find((p) => p.id !== thief.id)!;
|
|
giveCard(state, owner.id, "ward", "W", 0);
|
|
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
|
thief.position = { ...treasure.position! };
|
|
state = must(state, thief.id, { type: "pickUpTreasure" });
|
|
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
|
});
|
|
});
|
|
|
|
describe("zero-damage utility attacks", () => {
|
|
it("an uncountered DROP OBJECT is not reported as stopped — and the treasure falls", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const treasure = state.treasures.find((t) => t.owner !== defender)!;
|
|
treasure.carriedBy = defender;
|
|
treasure.position = null;
|
|
d.carriedTreasureId = treasure.id;
|
|
const dob = giveCard(state, attacker, "drop-object");
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
const result = applyCommand(state, defender, { type: "pass" });
|
|
if (!result.ok) throw new Error(result.error);
|
|
const resolved = result.events.find((e) => e.type === "attackResolved");
|
|
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(false);
|
|
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBeNull();
|
|
});
|
|
|
|
it("FULL SHIELD genuinely stops it — the treasure stays carried", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const treasure = state.treasures.find((t) => t.owner !== defender)!;
|
|
treasure.carriedBy = defender;
|
|
treasure.position = null;
|
|
d.carriedTreasureId = treasure.id;
|
|
const dob = giveCard(state, attacker, "drop-object");
|
|
giveCard(state, defender, "full-shield", "FS", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
const result = applyCommand(state, defender, { type: "pass" });
|
|
if (!result.ok) throw new Error(result.error);
|
|
const resolved = result.events.find((e) => e.type === "attackResolved");
|
|
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
|
|
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
|
|
});
|
|
});
|
|
|
|
describe("teleport as a counteraction", () => {
|
|
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const escape = { x: d.position.x, y: d.position.y + 1 };
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(after.life).toBe(15);
|
|
expect(cellKey(after.position)).toBe(cellKey(escape));
|
|
});
|
|
|
|
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
|
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const home = { ...d.position };
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
|
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
|
state = must(state, defender, { type: "pass" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(cellKey(after.position)).toBe(cellKey(home));
|
|
expect(after.life).toBe(10);
|
|
});
|
|
|
|
it("reaches at most four spaces", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
const far = { x: d.position.x, y: d.position.y + 5 };
|
|
expect(applyCommand(state, defender, {
|
|
type: "counteract", instanceId: tp.instanceId, params: { cell: far },
|
|
}).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("slime holds spells", () => {
|
|
function slimeRig() {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
|
state = toRound2(state);
|
|
const caster = activePlayer(state);
|
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
|
// A slime square beside the victim, with the victim adjacent to walk in.
|
|
const spot = { x: victim.position.x, y: victim.position.y };
|
|
const cellBeside = (() => {
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view, spot, side);
|
|
if (t.kind === "step" && !state.players.some((p) => cellKey(p.position) === cellKey(t.to)) &&
|
|
!boardView(state).homes.some((h) => cellKey(h) === cellKey(t.to))) {
|
|
return { cell: t.to, side };
|
|
}
|
|
}
|
|
throw new Error("setup: no free square beside the victim");
|
|
})();
|
|
state.squareContents[cellKey(cellBeside.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
|
|
// The caster stands with the victim: one open edge to the slime = clear LOS.
|
|
caster.position = { ...victim.position };
|
|
return { state, caster: caster.id, victim: victim.id, slimeAt: cellBeside };
|
|
}
|
|
|
|
it("a fireball cast at the slime waits, then goes off on the next visitor", () => {
|
|
const rig = slimeRig();
|
|
let state = rig.state;
|
|
const fb = giveCard(state, rig.caster, "fireball");
|
|
state = must(state, rig.caster, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
|
});
|
|
expect(Object.keys(state.slimeTraps).length).toBe(1);
|
|
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
|
// The victim walks in: stuck, and the fireball erupts.
|
|
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
|
expect(state.stack?.attackCard?.cardId).toBe("fireball");
|
|
expect(state.stack?.trapped).toBe(true);
|
|
state = must(state, rig.victim, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(10);
|
|
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
|
});
|
|
|
|
it("reflections off a trapped spell never touch its caster", () => {
|
|
const rig = slimeRig();
|
|
let state = rig.state;
|
|
const fb = giveCard(state, rig.caster, "fireball");
|
|
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
|
state = must(state, rig.caster, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
|
});
|
|
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
|
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
|
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
|
state = must(state, rig.caster, { type: "pass" });
|
|
state = must(state, rig.victim, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
|
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
|
});
|
|
|
|
it("a five-point waterbolt washes the slime (and its cargo) away", () => {
|
|
const rig = slimeRig();
|
|
let state = rig.state;
|
|
const fb = giveCard(state, rig.caster, "fireball");
|
|
state = must(state, rig.caster, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
|
});
|
|
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
|
const wb = giveCard(state, rig.victim, "waterbolt", "WB", 0);
|
|
giveCard(state, rig.victim, "number-5", "N", 1);
|
|
state = must(state, rig.victim, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-5#N"],
|
|
target: { kind: "cell", cell: rig.slimeAt.cell },
|
|
});
|
|
expect(state.squareContents[cellKey(rig.slimeAt.cell)]).toBeUndefined();
|
|
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("answering counteractions (FAQ rulings)", () => {
|
|
function rev6() {
|
|
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
|
|
}
|
|
|
|
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
|
let { state } = rev6();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
|
|
giveCard(state, defender, "full-reflection", "FR", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
|
|
state = must(state, attacker, { type: "counteract", instanceId: ab.instanceId });
|
|
state = must(state, defender, { type: "pass" });
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(a.hand.some((c) => c.instanceId === "full-reflection#FR")).toBe(true);
|
|
expect(a.life).toBe(15); // nothing reflected
|
|
expect(d.life).toBe(10); // the fireball lands after all
|
|
});
|
|
|
|
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
|
|
let { state } = rev6();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
|
|
giveCard(state, defender, "full-shield", "FS", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
|
const r = applyCommand(state, attacker, { type: "counteract", instanceId: ab.instanceId });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
|
|
});
|
|
|
|
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
|
|
let { state } = rev6();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
|
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const escape = { x: d.position.x, y: d.position.y + 1 };
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
|
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
|
expect(r.ok).toBe(false);
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(after.life).toBe(15);
|
|
expect(cellKey(after.position)).toBe(cellKey(escape));
|
|
});
|
|
});
|