import { describe, expect, it } from "vitest"; import { giveCard } from "./helpers"; import { edgeKey } from "../src/board"; import { applyCommand, activePlayer, createGame, redactEvent, BASE_MOVEMENT, HAND_LIMIT, STARTING_LIFE, type Command, type GameState, type PlayerId, } from "../src/game"; import { cellKey, stepTarget, SIDES, type Side } from "../src/board"; import { isNumberCard } from "../src/cards"; function newGame(seed = 42, players = ["alice", "bob"]) { return createGame({ playerIds: players, seed, sets: ["basic"] }); } 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; } /** Find a direction the active player can actually step. */ function openDirection(state: GameState): Side { const p = activePlayer(state); for (const side of SIDES) { if (stepTarget(state.board, p.position, side).kind !== "blocked") return side; } throw new Error("no open direction"); } describe("game setup", () => { it("is deterministic for a given seed", () => { const a = newGame(7); const b = newGame(7); expect(JSON.stringify(a.state)).toEqual(JSON.stringify(b.state)); expect(a.events).toEqual(b.events); }); it("deals 7 non-TRAP cards to each player, 15 life, wizard on home star", () => { const { state } = newGame(); for (const p of state.players) { expect(p.hand.length).toBe(HAND_LIMIT); expect(p.hand.some((c) => c.cardId === "trap")).toBe(false); expect(p.life).toBe(STARTING_LIFE); expect(cellKey(p.position)).toBe(cellKey(p.home)); } // Two treasures per player, on the sector's treasure spaces. expect(state.treasures.length).toBe(4); // Full deck conservation: deck + discard + hands = 125. const total = state.deck.length + state.discard.length + state.players.reduce((sum, p) => sum + p.hand.length, 0); expect(total).toBe(125); }); it("redacts private deal events for other players", () => { const { events } = newGame(); const alicePrivate = events.find( (e) => e.type === "cardsDealtPrivate" && e.visibleTo === "alice", )!; expect(redactEvent(alicePrivate, "bob")).toBeNull(); expect(redactEvent(alicePrivate, "alice")).toBe(alicePrivate); }); }); describe("movement", () => { it("allows 3 steps, then stops until a number card is played", () => { let { state } = newGame(); const me = activePlayer(state).id; for (let i = 0; i < BASE_MOVEMENT; i++) { state = must(state, me, { type: "move", direction: openDirection(state) }); } const refused = applyCommand(state, me, { type: "move", direction: openDirection(state) }); expect(refused.ok).toBe(false); const numberCard = activePlayer(state).hand.find((c) => isNumberCard(c.cardId)); if (numberCard) { state = must(state, me, { type: "playNumberForMovement", instanceId: numberCard.instanceId }); const again = applyCommand(state, me, { type: "move", direction: openDirection(state) }); expect(again.ok).toBe(true); } }); it("rejects out-of-turn commands", () => { const { state } = newGame(); const notMe = state.players.find((p) => p.id !== activePlayer(state).id)!.id; const result = applyCommand(state, notMe, { type: "move", direction: "N" }); expect(result.ok).toBe(false); }); }); describe("turns and combat", () => { it("forbids combat in round 1, allows punching in round 2 in the same square", () => { let { state } = newGame(); // Round 1: even a hypothetical punch is refused before target checks. const r1 = applyCommand(state, activePlayer(state).id, { type: "punch", targetId: state.players.find((p) => p.id !== activePlayer(state).id)!.id, }); expect(r1.ok).toBe(false); expect((r1 as { error: string }).error).toMatch(/first round/); // March both players' turns forward into round 2. state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); expect(state.turn.round).toBe(2); // Teleport the opponent into the active player's square (test surgery), // then punch: 1 damage, and a second punch the same turn is refused. const attacker = activePlayer(state); const victim = state.players.find((p) => p.id !== attacker.id)!; victim.position = { ...attacker.position }; state = must(state, attacker.id, { type: "punch", targetId: victim.id }); // Punches enter the counteraction stack (BLUNT/ABSORB work on physical // damage); the defender passes and the punch resolves. state = must(state, victim.id, { type: "pass" }); const v = state.players.find((p) => p.id === victim.id)!; expect(v.life).toBe(STARTING_LIFE - 1); const secondPunch = applyCommand(state, attacker.id, { type: "punch", targetId: victim.id, }); expect(secondPunch.ok).toBe(false); }); it("cannot punch yourself", () => { let { state } = newGame(); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); const me = activePlayer(state).id; const result = applyCommand(state, me, { type: "punch", targetId: me }); expect(result.ok).toBe(false); }); it("kill transfers the dead player's cards and forces a discard to 7", () => { let { state } = newGame(); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); const attacker = activePlayer(state); const victim = state.players.find((p) => p.id !== attacker.id)!; victim.position = { ...attacker.position }; victim.life = 1; // test surgery: one punch kills state = must(state, attacker.id, { type: "punch", targetId: victim.id }); state = must(state, victim.id, { type: "pass" }); const s = state; expect(s.players.find((p) => p.id === victim.id)!.alive).toBe(false); const a = s.players.find((p) => p.id === attacker.id)!; expect(a.hand.length).toBe(14); expect(s.pendingDiscard).toBe(attacker.id); // Game over check: 2 players, one dead -> last standing wins. expect(s.phase).toBe("finished"); expect(s.winner).toBe(attacker.id); }); it("draws up to two cards at end of turn, respecting the hand limit", () => { let { state } = newGame(); const me = activePlayer(state).id; const deckBefore = state.deck.length; // Hand is full (7): drawing 2 is capped to 0. state = must(state, me, { type: "endTurn", draw: 2 }); expect(state.deck.length).toBe(deckBefore); // Next player discards one, then ends turn drawing 2 -> capped to 1. const p2 = activePlayer(state); const discardOne = p2.hand[0]!.instanceId; state = must(state, p2.id, { type: "discard", instanceIds: [discardOne] }); state = must(state, p2.id, { type: "endTurn", draw: 2 }); const p2After = state.players.find((p) => p.id === p2.id)!; expect(p2After.hand.length).toBe(HAND_LIMIT); }); }); describe("treasures and victory", () => { it("stealing two enemy treasures to your home wins the game", () => { let { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; const [t1, t2] = state.treasures.filter((t) => t.owner === enemy.id); // Test surgery: place the wizard on an enemy treasure, pick it up (which // ends the turn's actions), walk it home across turns via direct position // edits (movement itself is tested elsewhere). me.position = { ...t1!.position! }; state = must(state, me.id, { type: "pickUpTreasure" }); // Actions ended: further movement refused. expect(applyCommand(state, me.id, { type: "move", direction: "N" }).ok).toBe(false); state = must(state, me.id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); let p = state.players.find((p) => p.id === me.id)!; p.position = { ...p.home }; state = must(state, me.id, { type: "dropTreasure" }); expect(state.phase).toBe("playing"); p = state.players.find((p) => p.id === me.id)!; p.position = { ...t2!.position! }; state = must(state, me.id, { type: "pickUpTreasure" }); state = must(state, me.id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); p = state.players.find((p) => p.id === me.id)!; p.position = { ...p.home }; state = must(state, me.id, { type: "dropTreasure" }); expect(state.phase).toBe("finished"); expect(state.winner).toBe(me.id); }); it("only one treasure may be carried at a time", () => { const { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; const [t1, t2] = state.treasures.filter((t) => t.owner === enemy.id); me.position = { ...t1!.position! }; const s1 = must(state, me.id, { type: "pickUpTreasure" }); // Stand on the second treasure: the pickup is still refused ("one at a time"). s1.players.find((p) => p.id === me.id)!.position = { ...t2!.position! }; const result = applyCommand(s1, me.id, { type: "pickUpTreasure" }); expect(result.ok).toBe(false); }); }); describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => { it("the carried treasure lands where the wizard stood", () => { let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 }); const A = state.players.find((p) => p.id === "a")!; const B = state.players.find((p) => p.id === "b")!; const C = state.players.find((p) => p.id === "c")!; // C's first treasure already sits captured on B's home... const c1 = state.treasures.filter((t) => t.owner === "c")[0]!; c1.position = { ...B.home }; // ...C carries one of B's treasures... const bt = state.treasures.filter((t) => t.owner === "b")[0]!; bt.position = null; bt.carriedBy = "c"; C.carriedTreasureId = bt.id; // ...and A, standing on their own home, drops C's second treasure there. const c2 = state.treasures.filter((t) => t.owner === "c")[1]!; c2.position = null; c2.carriedBy = "a"; A.carriedTreasureId = c2.id; A.position = { ...A.home }; while (state.players[state.turn.activeIndex]!.id !== "a") { const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); state = r.state; } const r = applyCommand(state, "a", { type: "dropTreasure" }); if (!r.ok) throw new Error(r.error); state = r.state; // C is eliminated by lost treasures — and B's treasure lies where C stood. const cAfter = state.players.find((p) => p.id === "c")!; expect(cAfter.alive).toBe(false); expect(cAfter.carriedTreasureId).toBeNull(); const btAfter = state.treasures.find((t) => t.id === bt.id)!; expect(btAfter.carriedBy).toBeNull(); expect(btAfter.position).toEqual(cAfter.position); }); }); describe("the Ward is played in the moment (rules rev 31)", () => { function grabRig() { let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 }); const thief = state.players.find((p) => p.id === "thief")!; const owner = state.players.find((p) => p.id === "owner")!; giveCard(state, "owner", "ward", "W", 0); const t = state.treasures.find((t) => t.owner === "owner" && t.position)!; thief.position = { ...t.position! }; while (state.players[state.turn.activeIndex]!.id !== "thief") { const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); state = r.state; } const r = applyCommand(state, "thief", { type: "pickUpTreasure" }); if (!r.ok) throw new Error(r.error); return { state: r.state, owner, thief }; } it("the grab hangs on the owner; springing costs the thief 3 and the card", () => { let { state } = grabRig(); expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" }); // Nobody else may act while it hangs. expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false); const r = applyCommand(state, "owner", { type: "wardChoice", play: true }); if (!r.ok) throw new Error(r.error); state = r.state; expect(state.wardPending).toBeNull(); expect(state.players.find((p) => p.id === "thief")!.life).toBe(12); expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false); }); it("declining lets the thief go, Ward still in hand", () => { let { state } = grabRig(); const r = applyCommand(state, "owner", { type: "wardChoice", play: false }); if (!r.ok) throw new Error(r.error); state = r.state; expect(state.players.find((p) => p.id === "thief")!.life).toBe(15); expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(true); expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true); }); it("arming is refused in this vintage", () => { let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 }); giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0); const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true }); expect(r.ok).toBe(false); }); }); describe("an ambushed teleport carries its destination (rules rev 35)", () => { it("the trap springs and the victim lands where the trapper said", () => { let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 }); for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) { const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); if (!r.ok) throw new Error(r.error); state = r.state; } const trapper = state.players.find((p) => p.id === "trapper")!; const prey = state.players.find((p) => p.id === "prey")!; giveCard(state, "trapper", "interrupt", "I", 0); giveCard(state, "trapper", "teleport-opponent", "TO", 1); // Arming without a destination is refused; with one it is stored. const bad = applyCommand(state, "trapper", { type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" }, spellInstanceId: "teleport-opponent#TO", }); expect(bad.ok).toBe(false); const dest = { x: trapper.home.x, y: trapper.home.y === 0 ? 1 : trapper.home.y - 1 }; let r = applyCommand(state, "trapper", { type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" }, spellInstanceId: "teleport-opponent#TO", cell: dest, }); if (!r.ok) throw new Error(r.error); state = r.state; state = applyCommand(state, "trapper", { type: "endTurn", draw: 0 }).ok ? (applyCommand(state, "trapper", { type: "endTurn", draw: 0 }) as { state: typeof state }).state : state; // The prey walks adjacent; the trap springs; the prey passes; they land // at dest. (Re-find both: applyCommand clones made the handles stale.) const trapperNow = state.players.find((p) => p.id === "trapper")!; const preyNow = state.players.find((p) => p.id === "prey")!; const below = trapperNow.position.y >= 2; preyNow.position = { x: trapperNow.position.x, y: trapperNow.position.y + (below ? -2 : 2) }; const dir = below ? "S" : "N"; state.edgeOverrides[edgeKey(preyNow.position, dir)] = "open"; r = applyCommand(state, "prey", { type: "move", direction: dir }); if (!r.ok) throw new Error(r.error); state = r.state; expect(state.stack?.attackCard?.cardId).toBe("teleport-opponent"); expect(state.stack?.params?.cell).toEqual(dest); r = applyCommand(state, "prey", { type: "pass" }); if (!r.ok) throw new Error(r.error); state = r.state; expect(state.players.find((p) => p.id === "prey")!.position).toEqual(dest); }); });