import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, createGame, boardView, type Command, type GameState, type PlayerId, } from "../src/game"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; function newGame(seed = 42) { return createGame({ playerIds: ["alice", "bob"], 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; } /** Test surgery: put a specific card into a player's hand (swapping one out). */ function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T"): CardInstance { const p = state.players.find((p) => p.id === playerId)!; const instance = { instanceId: `${cardId}#${tag}`, cardId }; p.hand[0] = instance; return instance; } /** Advance past round 1 (both players just end their turns). */ 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 }); expect(state.turn.round).toBe(2); return state; } /** Put attacker and defender in mutual LOS (same square works for spells too). */ 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 }; } 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)!; // Stand defender 1 east of attacker with open space behind (find a spot): // use same square then nudge — simplest robust arrangement: same square, // knockback direction defaults to none, so instead place east if open. 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, "chaos"); // expansion1, unimplemented 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/); }); });