import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, createGame, sustainedOn, type Command, type GameState, type PlayerId, } from "../src/game"; import { cellKey } from "../src/board"; import type { CardInstance } from "../src/cards"; function newGame(seed = 42) { return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] }); } function must(state: GameState, player: PlayerId, command: Command): GameState { const result = applyCommand(state, player, command); if (!result.ok) throw new Error(`command failed: ${result.error}`); return result.state; } function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { const p = state.players.find((p) => p.id === playerId)!; const instance = { instanceId: `${cardId}#${tag}`, cardId }; p.hand[slot] = instance; return instance; } function toRound2(state: GameState): GameState { state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); return state; } function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } { const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; defender.position = { ...attacker.position }; return { attacker: attacker.id, defender: defender.id }; } function castAt( state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance, extra: Partial> = {}, ): GameState { state = must(state, attacker, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: defender }, ...extra, }); return must(state, defender, { type: "pass" }); } 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; } if (steps === 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)); } }); }); 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); }); });