import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, createGame, boardView, gameLos, sustainedOn, type Command, type GameState, type PlayerId, } from "../src/game"; import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, 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; } 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" }); } /** An empty visible cell adjacent to the player (not home, no treasure). */ function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } { const view = boardView(state); for (const side of SIDES) { const t = stepTarget(view, of, side); if (t.kind !== "step") continue; const key = cellKey(t.to); if (view.homes.some((h) => cellKey(h) === key)) continue; if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue; if (state.players.some((p) => cellKey(p.position) === key)) continue; return { cell: t.to, side }; } throw new Error("no empty neighbor"); } describe("terrain", () => { it("fill square with stone blocks movement and line of sight", () => { let { state } = newGame(); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const fs = giveCard(state, me.id, "fill-square-with-stone"); state = must(state, me.id, { type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell }, }); expect(applyCommand(state, me.id, { type: "move", direction: spot.side }).ok).toBe(false); // LOS straight through the stone is blocked. const beyond = neighbor(spot.cell, spot.side); if (boardView(state).cells[cellKey(beyond)]) { expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false); } }); it("thornbush entry costs a life point, the turn, and the next turn", () => { let { state } = newGame(); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const tb = giveCard(state, me.id, "thornbush"); state = must(state, me.id, { type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot.cell }, }); state = must(state, me.id, { type: "move", direction: spot.side }); const p = state.players.find((p) => p.id === me.id)!; expect(p.life).toBe(14); expect(p.lostTurns).toBe(1); expect(state.turn.actionsEnded).toBe(true); }); it("wizards in a thornbush cannot attack or be attacked", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const d = state.players.find((p) => p.id === defender)!; // Test surgery: plant a bush and stand the defender in it. state.squareContents[cellKey(d.position)] = { kind: "thornbush", damage: 0, createdBy: attacker }; const fb = giveCard(state, attacker, "fireball"); const refused = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, }); expect(refused.ok).toBe(false); }); it("wall of fire burns crossers and expires with its duration", () => { let { state } = newGame(); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const key = edgeKey(me.position, spot.side); const wof = giveCard(state, me.id, "wall-of-fire"); giveCard(state, me.id, "number-2", "N", 1); state = must(state, me.id, { type: "cast", instanceId: wof.instanceId, numberInstanceIds: ["number-2#N"], target: { kind: "edge", cell: me.position, side: spot.side }, }); expect(boardView(state).edges[key]).toBe("firewall"); // Walking through it hurts. state = must(state, me.id, { type: "move", direction: spot.side }); expect(state.players.find((p) => p.id === me.id)!.life).toBe(11); // Duration 2: expires at the start of the caster's second following turn. state = must(state, me.id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); expect(boardView(state).edges[key]).toBe("firewall"); // 1 turn left state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); expect(boardView(state).edges[key]).toBeUndefined(); }); it("dispel creation removes a created wall", () => { let { state } = newGame(); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const key = edgeKey(me.position, spot.side); const cw = giveCard(state, me.id, "create-wall"); state = must(state, me.id, { type: "cast", instanceId: cw.instanceId, target: { kind: "edge", cell: me.position, side: spot.side }, }); expect(boardView(state).edges[key]).toBe("wall"); const dc = giveCard(state, me.id, "dispel-creation"); state = must(state, me.id, { type: "cast", instanceId: dc.instanceId, target: { kind: "edge", cell: me.position, side: spot.side }, }); expect(boardView(state).edges[key]).toBeUndefined(); // But a printed (original) wall cannot be dispelled. const dc2 = giveCard(state, me.id, "dispel-creation", "T2"); const view = boardView(state); const wallEntry = Object.entries(view.edges).find(([, s]) => s === "wall")!; const [kind, coords] = wallEntry[0].split(":") as [string, string]; const [x, y] = coords.split(",").map(Number) as [number, number]; const refused = applyCommand(state, me.id, { type: "cast", instanceId: dc2.instanceId, target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" }, }); expect(refused.ok).toBe(false); }); }); describe("objects", () => { it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const dagger = giveCard(state, attacker, "dagger"); giveCard(state, defender, "full-shield", "FS", 0); state = must(state, attacker, { type: "cast", instanceId: dagger.instanceId, target: { kind: "player", playerId: defender }, }); state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" }); state = must(state, attacker, { type: "pass" }); state = must(state, defender, { type: "pass" }); const d = state.players.find((p) => p.id === defender)!; expect(d.life).toBe(12); // full shield "does not stop any physical attack" const floor = state.groundObjects[cellKey(d.position)] ?? []; expect(floor.some((c) => c.cardId === "dagger")).toBe(true); // Anyone may pick it up — and doing so ends the turn's actions. state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "pickUpObject", instanceId: dagger.instanceId }); expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true); expect(state.turn.actionsEnded).toBe(true); }); it("blunt halves a thrown rock's physical damage", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const rock = giveCard(state, attacker, "large-rock"); giveCard(state, defender, "blunt", "B", 0); state = must(state, attacker, { type: "cast", instanceId: rock.instanceId, target: { kind: "player", playerId: defender }, }); state = must(state, defender, { type: "counteract", instanceId: "blunt#B" }); state = must(state, attacker, { type: "pass" }); state = must(state, defender, { type: "pass" }); expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // ceil(2/2)=1 }); it("drop object forces a named object to the floor; drag pulls a treasure home", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); giveCard(state, defender, "dagger", "D2", 0); const dobj = giveCard(state, attacker, "drop-object"); state = castAt(state, attacker, defender, dobj, { params: { cardId: "dagger" } }); const dPos = state.players.find((p) => p.id === defender)!.position; expect((state.groundObjects[cellKey(dPos)] ?? []).some((c) => c.cardId === "dagger")).toBe(true); // Drag an enemy treasure across open floor toward the caster. state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "endTurn", draw: 0 }); const enemyTreasure = state.treasures.find((t) => t.owner === defender && t.position)!; const me2 = state.players.find((p) => p.id === attacker)!; // Stand adjacent-ish to the treasure with clear LOS: same square works. me2.position = { ...enemyTreasure.position! }; const drag = giveCard(state, attacker, "drag"); state = must(state, attacker, { type: "cast", instanceId: drag.instanceId, target: { kind: "cell", cell: enemyTreasure.position! }, }); const t = state.treasures.find((tr) => tr.id === enemyTreasure.id)!; expect(cellKey(t.position!)).toBe(cellKey(state.players.find((p) => p.id === attacker)!.position)); }); }); describe("control effects", () => { it("lock in place stops moving and being moved", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const lip = giveCard(state, attacker, "lock-in-place"); giveCard(state, attacker, "number-3", "N", 1); state = castAt(state, attacker, defender, lip, { numberInstanceIds: ["number-3#N"] }); expect(sustainedOn(state, defender, "lock-in-place").length).toBe(1); state = must(state, attacker, { type: "endTurn", draw: 0 }); expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false); state = must(state, defender, { type: "endTurn", draw: 0 }); // Teleport Opponent fizzles against a locked target. const tpo = giveCard(state, attacker, "teleport-opponent"); const before = state.players.find((p) => p.id === defender)!.position; const anywhere = state.board.homes.find((h) => cellKey(h) !== cellKey(before))!; state = castAt(state, attacker, defender, tpo, { params: { cell: anywhere } }); expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(before)); }); it("buddy prevents attacks until the caster breaks the pact", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const buddy = giveCard(state, attacker, "buddy"); state = must(state, attacker, { type: "cast", instanceId: buddy.instanceId, target: { kind: "player", playerId: defender }, }); state = must(state, attacker, { type: "endTurn", draw: 0 }); // The defender cannot bring themselves to attack the caster. const fb = giveCard(state, defender, "fireball", "F", 0); const refused = applyCommand(state, defender, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker }, }); expect(refused.ok).toBe(false); state = must(state, defender, { type: "endTurn", draw: 0 }); // The caster attacks first: the pact breaks; next turn the defender may. const fb2 = giveCard(state, attacker, "fireball", "F2", 0); state = castAt(state, attacker, defender, fb2); expect(state.sustained.some((s) => s.cardId === "buddy")).toBe(false); }); it("mist body passes through walls but cannot attack or be attacked", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const mist = giveCard(state, defender, "mist-body", "M", 0); giveCard(state, defender, "number-3", "N", 1); state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "cast", instanceId: mist.instanceId, numberInstanceIds: ["number-3#N"], }); // Misted wizard walks through a wall if one is adjacent. const view = boardView(state); const d = state.players.find((p) => p.id === defender)!; for (const side of SIDES) { const k = edgeKey(d.position, side); if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(d.position, side))]) { state = must(state, defender, { type: "move", direction: side }); break; } } state = must(state, defender, { type: "endTurn", draw: 0 }); const fb = giveCard(state, attacker, "fireball", "F", 0); const dd = state.players.find((p) => p.id === defender)!; state.players.find((p) => p.id === attacker)!.position = { ...dd.position }; const refused = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, }); expect(refused.ok).toBe(false); }); it("reuse spell retrieves the last spell you cast", () => { let { state } = newGame(); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const cw = giveCard(state, me.id, "create-wall"); state = must(state, me.id, { type: "cast", instanceId: cw.instanceId, target: { kind: "edge", cell: me.position, side: spot.side }, }); const ru = giveCard(state, me.id, "reuse-spell"); state = must(state, me.id, { type: "cast", instanceId: ru.instanceId }); expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true); }); });