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); }); }); 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); }); }); 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"], deckRev: 12 }); 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 (rev 10+). 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"], deckRev: 15 }); 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 (rules rev 16)", () => { it("a believed illusion bites both wizards", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 }); 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 (rules rev 25)", () => { function reflectRig() { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 }); 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); }); it("rev 24 lands the reflected blow instantly, as stored games replay", () => { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 }); 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" }); state = must(state, attacker, { type: "pass" }); expect(state.stack).toBeNull(); expect(state.players.find((p) => p.id === attacker)!.life).toBe(5); }); }); describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () => { function boltRig(deckRev: number) { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev }); 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(28); 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); }); it("rev 27 keeps the old behavior: the caster plays on and draws", () => { let { state, attacker } = boltRig(27); expect(state.turn.actionsEnded).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).toBeGreaterThan(before); }); }); describe("fireball burns only the stones in play (rules rev 33)", () => { it("a displayed stone dies; a hidden one stays secret and safe", () => { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 }); 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 (rules rev 34)", () => { it("hidden stones neither add damage nor betray their count", () => { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 }); 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 = 3 damage... minus the displayed bloodstone? It is // hidden, so no soak either: 15 - 3 = 12. expect(state.players.find((p) => p.id === defender)!.life).toBe(12); }); });