import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game"; import { cellKey, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; describe("expansion combat cards", () => { it("power attack burns life for extra damage", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const fb = giveCard(state, attacker, "fireball"); giveCard(state, attacker, "power-attack", "PA", 1); state = castAt(state, attacker, defender, fb, { powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 3, }); expect(state.players.find((p) => p.id === defender)!.life).toBe(7); // 5+3 expect(state.players.find((p) => p.id === attacker)!.life).toBe(12); }); it("weakness doubles damage taken and forbids carrying treasure", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const wk = giveCard(state, attacker, "weakness"); giveCard(state, attacker, "number-3", "N", 1); state = castAt(state, attacker, defender, wk, { numberInstanceIds: ["number-3#N"] }); expect(sustainedOn(state, defender, "weakness").length).toBe(1); state = must(state, attacker, { type: "endTurn", draw: 0 }); const t = state.treasures.find((t) => t.owner === attacker && t.position)!; const d = state.players.find((p) => p.id === defender)!; d.position = { ...t.position! }; expect(applyCommand(state, defender, { type: "pickUpTreasure" }).ok).toBe(false); state = must(state, defender, { type: "endTurn", draw: 0 }); const fb = giveCard(state, attacker, "fireball", "F", 0); const d2 = state.players.find((p) => p.id === defender)!; d2.position = { ...state.players.find((p) => p.id === attacker)!.position }; state = castAt(state, attacker, defender, fb); expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5x2 }); it("walking dead bleeds half a point per space walked", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const wd = giveCard(state, attacker, "walking-dead"); state = castAt(state, attacker, defender, wd); state = must(state, attacker, { type: "endTurn", draw: 0 }); // Defender walks: every second step costs a point. let lifeStart = state.players.find((p) => p.id === defender)!.life; let steps = 0; for (const dir of ["N", "S", "E", "W", "N", "S"] as const) { const r = applyCommand(state, defender, { type: "move", direction: dir }); if (r.ok) { state = r.state; steps++; } if (steps === 2) break; } expect(steps).toBe(2); expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1); }); it("mental swap trades entire hands", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const ms = giveCard(state, attacker, "mental-swap"); const aCards = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId); const dCards = state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId); state = castAt(state, attacker, defender, ms); const aAfter = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId); expect(aAfter).toEqual(dCards); // (the swap card itself was consumed from the attacker's hand pre-swap) expect(state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId)) .toEqual(aCards.filter((id) => id !== ms.instanceId)); }); it("butt-head rams for the distance charged", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; // Stand them 3 apart on the same column if possible; else same square +N. defender.position = { x: attacker.position.x, y: attacker.position.y >= 3 ? attacker.position.y - 3 : attacker.position.y + 3 }; const bh = giveCard(state, attacker.id, "butt-head"); state = castAt(state, attacker.id, defender.id, bh); const a = state.players.find((p) => p.id === attacker.id)!; const d = state.players.find((p) => p.id === defender.id)!; expect(cellKey(a.position)).toBe(cellKey(d.position)); expect(d.life).toBe(12); // 3 spaces = 3 damage }); it("empathy turns an attack back on its caster as well", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); // Defender raises empathy on their own turn. state = must(state, attacker, { type: "endTurn", draw: 0 }); const em = giveCard(state, defender, "empathy", "E", 0); giveCard(state, defender, "number-3", "N", 1); state = must(state, defender, { type: "cast", instanceId: em.instanceId, numberInstanceIds: ["number-3#N"], }); state = must(state, defender, { type: "endTurn", draw: 0 }); const fb = giveCard(state, attacker, "fireball", "F", 0); state = castAt(state, attacker, defender, fb); expect(state.players.find((p) => p.id === defender)!.life).toBe(10); expect(state.players.find((p) => p.id === attacker)!.life).toBe(10); }); it("ward springs when a trapped treasure is grabbed", () => { let { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; giveCard(state, enemy.id, "ward", "W", 0); const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!; me.position = { ...treasure.position! }; state = must(state, me.id, { type: "pickUpTreasure" }); expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); }); it("opportunity fire opens an out-of-turn attack window", () => { let { state } = newGame(); state = toRound2(state); const active = activePlayer(state).id; const lurker = state.players.find((p) => p.id !== active)!; lurker.position = { ...state.players.find((p) => p.id === active)!.position }; const of_ = giveCard(state, lurker.id, "opportunity-fire", "OF", 0); const fb = giveCard(state, lurker.id, "fireball", "F", 1); // Out of turn: play opportunity fire, then the attack. state = must(state, lurker.id, { type: "cast", instanceId: of_.instanceId }); expect(state.outOfTurnWindow?.playerId).toBe(lurker.id); state = must(state, lurker.id, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: active }, }); state = must(state, active, { type: "pass" }); expect(state.players.find((p) => p.id === active)!.life).toBe(10); // Turn structure is intact: the original player is still active. expect(activePlayer(state).id).toBe(active); expect(state.outOfTurnWindow).toBeNull(); }); it("idiot marches its victim toward their own treasure and forbids casting", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const id = giveCard(state, attacker, "idiot"); state = castAt(state, attacker, defender, id); expect(sustainedOn(state, defender, "idiot").length).toBe(1); state = must(state, attacker, { type: "endTurn", draw: 0 }); // Casting is refused; moving is steered (any direction request works). const fb = giveCard(state, defender, "fireball", "F", 0); expect(applyCommand(state, defender, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker }, }).ok).toBe(false); const before = state.players.find((p) => p.id === defender)!.position; const r = applyCommand(state, defender, { type: "move", direction: "N" }); if (r.ok) { const after = r.state.players.find((p) => p.id === defender)!.position; expect(cellKey(after)).not.toBe(cellKey(before)); } else { expect(r.error).toMatch(/blocked/); } }); it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, }); state = toRound2(state); const { attacker, defender } = faceOff(state); // The victim carries an OPPONENT'S treasure into the spell. const d = state.players.find((p) => p.id === defender)!; const stolen = state.treasures.find((t) => t.owner === attacker)!; stolen.carriedBy = defender; stolen.position = null; d.carriedTreasureId = stolen.id; const id = giveCard(state, attacker, "idiot"); state = castAt(state, attacker, defender, id); expect(sustainedOn(state, defender, "idiot").length).toBe(1); state = must(state, attacker, { type: "endTurn", draw: 0 }); // No dropping the carried treasure (no capturing it on your home, either). const drop = applyCommand(state, defender, { type: "dropTreasure" }); expect(drop.ok).toBe(false); if (!drop.ok) expect(drop.error).toMatch(/treasure/); // No punching the tormentor. expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false); // Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall"). const sp = giveCard(state, defender, "speed", "SP", 0); expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true); // Counteractions are expressly allowed: absorb an incoming fireball. state = must(state, defender, { type: "endTurn", draw: 0 }); const fb = giveCard(state, attacker, "fireball", "F", 0); state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, }); giveCard(state, defender, "absorb-spell", "AB", 1); expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true); }); it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, }); state = toRound2(state); const { attacker, defender } = faceOff(state); const d = state.players.find((p) => p.id === defender)!; const own = state.treasures.find((t) => t.owner === defender)!; own.carriedBy = defender; own.position = null; d.carriedTreasureId = own.id; const id = giveCard(state, attacker, "idiot"); state = castAt(state, attacker, defender, id); expect(sustainedOn(state, defender, "idiot").length).toBe(0); }); }); describe("swap home bases", () => { it("trades homes when both bases hold equal treasures", () => { let { state } = newGame(); const me = activePlayer(state); const other = state.players.find((p) => p.id !== me.id)!; other.position = { ...me.position }; // LOS guaranteed const myHome = { ...me.home }; const theirHome = { ...other.home }; const shb = giveCard(state, me.id, "swap-home-bases"); state = must(state, me.id, { type: "cast", instanceId: shb.instanceId, target: { kind: "player", playerId: other.id }, }); expect(cellKey(state.players.find((p) => p.id === me.id)!.home)).toBe(cellKey(theirHome)); expect(cellKey(state.players.find((p) => p.id === other.id)!.home)).toBe(cellKey(myHome)); }); }); describe("thumb of god (divine meteor)", () => { it("scatters every token near where the die lands", () => { let { state } = newGame(); state = toRound2(state); const me = activePlayer(state); // Sprinkle the blast zone: a ground object and the enemy nearby. const enemy = state.players.find((p) => p.id !== me.id)!; enemy.position = { ...me.position }; state.groundObjects[cellKey(me.position)] = [{ instanceId: "dagger#G", cardId: "dagger" }]; const tog = giveCard(state, me.id, "thumb-of-god"); state = must(state, me.id, { type: "cast", instanceId: tog.instanceId, target: { kind: "cell", cell: me.position }, }); expect(state.turn.attackUsed).toBe(true); // The dagger moved somewhere on the board. const allObjects = Object.values(state.groundObjects).flat(); expect(allObjects.some((c) => c.cardId === "dagger")).toBe(true); }); }); describe("add for movement", () => { it("an ADD permits a second number card on movement, once", () => { let { state } = newGame(); const me = activePlayer(state).id; giveCard(state, me, "number-3", "N1", 0); giveCard(state, me, "number-2", "N2", 1); giveCard(state, me, "add", "A", 2); giveCard(state, me, "number-4", "N3", 3); state = must(state, me, { type: "playNumberForMovement", instanceId: "number-3#N1" }); expect(state.turn.movementAllowance).toBe(6); // Second without ADD: refused. expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2" }).ok).toBe(false); // With ADD: allowed. state = must(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2", addInstanceId: "add#A" }); expect(state.turn.movementAllowance).toBe(8); // A third is refused even with another add wished for. expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-4#N3" }).ok).toBe(false); }); }); describe("ambushes (async interrupts)", () => { it("an armed Opportunity Fire springs when prey walks into sight", () => { let { state } = newGame(); state = toRound2(state); const owner = activePlayer(state); const of_ = giveCard(state, owner.id, "opportunity-fire", "OF", 0); const fb = giveCard(state, owner.id, "fireball", "FB", 1); state = must(state, owner.id, { type: "setAmbush", instanceId: of_.instanceId, trigger: { kind: "los" }, spellInstanceId: fb.instanceId, }); const o = state.players.find((p) => p.id === owner.id)!; expect(o.hand.some((c) => c.cardId === "fireball")).toBe(false); expect(state.ambushes.length).toBe(1); state = must(state, owner.id, { type: "endTurn", draw: 0 }); const preyNow = state.players.find((p) => p.id !== owner.id)!; const ownerNow = state.players.find((p) => p.id === owner.id)!; // Find a step that goes from a no-LOS cell into a LOS cell. let found: { from: { x: number; y: number }; side: "N" | "S" | "E" | "W" } | null = null; outer: for (const key of Object.keys(state.board.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; const cell = { x, y }; if (!gameLos(state, ownerNow.position, cell)) continue; for (const side of ["N", "S", "E", "W"] as const) { const dx = side === "E" ? 1 : side === "W" ? -1 : 0; const dy = side === "S" ? 1 : side === "N" ? -1 : 0; const from = { x: x - dx, y: y - dy }; if (!state.board.cells[cellKey(from)]) continue; if (gameLos(state, ownerNow.position, from)) continue; const st = stepTarget(boardView(state), from, side); if (st.kind === "step" && cellKey(st.to) === key) { found = { from, side }; break outer; } } } expect(found).not.toBeNull(); preyNow.position = found!.from; state = must(state, preyNow.id, { type: "move", direction: found!.side }); expect(state.stack).not.toBeNull(); expect(state.stack!.attackerId).toBe(owner.id); expect(state.stack!.attackCard!.cardId).toBe("fireball"); expect(state.ambushes.length).toBe(0); state = must(state, preyNow.id, { type: "pass" }); expect(state.players.find((p) => p.id === preyNow.id)!.life).toBe(10); }); it("cancelling an ambush returns the committed cards", () => { let { state } = newGame(); const owner = activePlayer(state); const int_ = giveCard(state, owner.id, "interrupt", "I", 0); const lb = giveCard(state, owner.id, "lightning-blast", "LB", 1); giveCard(state, owner.id, "number-3", "N", 2); state = must(state, owner.id, { type: "setAmbush", instanceId: int_.instanceId, trigger: { kind: "near" }, spellInstanceId: lb.instanceId, numberInstanceIds: ["number-3#N"], }); const id = state.ambushes[0]!.id; state = must(state, owner.id, { type: "cancelAmbush", ambushId: id }); const o = state.players.find((p) => p.id === owner.id)!; expect(o.hand.some((c) => c.cardId === "interrupt")).toBe(true); expect(o.hand.some((c) => c.cardId === "lightning-blast")).toBe(true); expect(state.ambushes.length).toBe(0); }); }); describe("swap meet trades carried items (rules rev 26)", () => { function rig(deckRev: number) { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); state = toRound2(state); const { attacker, defender } = faceOff(state); const sm = giveCard(state, attacker, "swap-meet"); giveCard(state, attacker, "dagger", "D", 1); giveCard(state, defender, "blaster-wand", "W", 0); state = must(state, attacker, { type: "cast", instanceId: sm.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: "dagger;blaster-wand" }, }); state = must(state, defender, { type: "pass" }); return { state, attacker, defender }; } it("a dagger trades for a wand — both are carried items", () => { const { state, attacker, defender } = rig(26); expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true); }); it("older revisions matched only object-typed cards and replay so", () => { const { state, attacker, defender } = rig(25); expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true); expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true); }); }); describe("swap meet trades treasures too", () => { function treasureRig(theirsToken: string, mineToken: string) { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; const d = state.players.find((p) => p.id === defender)!; // The defender hauls one of the attacker's own treasures. const stolen = state.treasures.find((t) => t.owner === attacker)!; stolen.position = null; stolen.carriedBy = defender; d.carriedTreasureId = stolen.id; const sm = giveCard(state, attacker, "swap-meet"); giveCard(state, attacker, "dagger", "D", 1); state = must(state, attacker, { type: "cast", instanceId: sm.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: `${mineToken};${theirsToken}` }, }); state = must(state, defender, { type: "pass" }); return { state, attacker, defender, a, d, stolen }; } it("a dagger buys back the treasure in the thief's arms", () => { const { state, attacker, defender } = treasureRig("treasure", "dagger"); const a = state.players.find((p) => p.id === attacker)!; const d = state.players.find((p) => p.id === defender)!; const t = state.treasures.find((tr) => tr.owner === attacker && tr.carriedBy)!; expect(a.carriedTreasureId).toBe(t.id); expect(t.carriedBy).toBe(attacker); expect(d.carriedTreasureId).toBeNull(); expect(d.hand.some((c) => c.cardId === "dagger")).toBe(true); }); it("the trade refuses to overload full arms", () => { // The attacker also carries a treasure: claiming theirs with a dagger // would mean two in hand — the swap quietly cannot happen. let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; const d = state.players.find((p) => p.id === defender)!; const t1 = state.treasures.find((t) => t.owner === attacker)!; t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id; const t2 = state.treasures.find((t) => t.owner === defender)!; t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id; const sm = giveCard(state, attacker, "swap-meet"); giveCard(state, attacker, "dagger", "D", 1); state = must(state, attacker, { type: "cast", instanceId: sm.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: "dagger;treasure" }, }); state = must(state, defender, { type: "pass" }); expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t1.id); expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t2.id); }); it("treasure for treasure trades both armfuls", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 }); state = toRound2(state); const { attacker, defender } = faceOff(state); const a = state.players.find((p) => p.id === attacker)!; const d = state.players.find((p) => p.id === defender)!; const t1 = state.treasures.find((t) => t.owner === attacker)!; t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id; const t2 = state.treasures.find((t) => t.owner === defender)!; t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id; const sm = giveCard(state, attacker, "swap-meet"); state = must(state, attacker, { type: "cast", instanceId: sm.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: "treasure;treasure" }, }); state = must(state, defender, { type: "pass" }); expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t2.id); expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t1.id); expect(state.treasures.find((t) => t.id === t1.id)!.carriedBy).toBe(defender); expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker); }); });