import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, type Command, type GameState, type PlayerId, } from "../src/game"; import { cellKey, stepTarget } 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); }); }); 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); }); });