import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board"; import type { CardInstance } from "../src/cards"; 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); }); it("unimplemented cards refuse to cast with a clear error", () => { let { state } = newGame(); const caster = activePlayer(state); const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toMatch(/not implemented/); }); }); 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", async () => { const { viewFor } = await import("../src/view"); 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("a powered fireball brings 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("rules revision 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. state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!; 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); }); });