import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, createGame, boardView, gameLos, sustainedOn, viewFor, type Command, type GameState, type PlayerId, } from "../src"; 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 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("around the corner", () => { it("bends line of sight past a wall that blocks a straight cast", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; // Find a hidden cell (no direct LOS) that IS reachable with one bend. const view = boardView(state); let hidden: Cell | null = null; outer: for (const key of Object.keys(view.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; const cell = { x, y }; if (gameLos(state, attacker.position, cell)) continue; for (const midKey of Object.keys(view.cells)) { const [mx, my] = midKey.split(",").map(Number) as [number, number]; const mid = { x: mx, y: my }; if (gameLos(state, attacker.position, mid) && gameLos(state, mid, cell)) { hidden = cell; break outer; } } } expect(hidden).not.toBeNull(); defender.position = hidden!; const fb = giveCard(state, attacker.id, "fireball", "F", 0); giveCard(state, attacker.id, "around-the-corner", "ATC", 1); // Straight cast: refused. const straight = applyCommand(state, attacker.id, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id }, }); expect(straight.ok).toBe(false); // Bent cast: lands. state = must(state, attacker.id, { type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC", target: { kind: "player", playerId: defender.id }, }); state = must(state, defender.id, { type: "pass" }); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10); }); }); describe("blind", () => { it("blinded movement lurches in rolled directions and bumps cost movement", () => { let { state } = newGame(7); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; defender.position = { ...attacker.position }; const bl = giveCard(state, attacker.id, "blind"); giveCard(state, attacker.id, "number-3", "N", 1); state = must(state, attacker.id, { type: "cast", instanceId: bl.instanceId, numberInstanceIds: ["number-3#N"], target: { kind: "player", playerId: defender.id }, }); state = must(state, defender.id, { type: "pass" }); expect(sustainedOn(state, defender.id, "blind").length).toBe(1); state = must(state, attacker.id, { type: "endTurn", draw: 0 }); // Every move consumes exactly one movement point whether it lands or bumps. const before = state.turn.movementUsed; state = must(state, defender.id, { type: "move", direction: "N" }); expect(state.turn.movementUsed).toBe(before + 1); }); it("blinded casts fly in a rolled direction and can miss entirely", () => { // Across seeds: a blinded caster aiming at a real target sometimes hits // (roll matches), usually misses (attack dissipates, card still spent). let hits = 0, misses = 0; for (let seed = 1; seed <= 10; seed++) { let { state } = newGame(seed); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; // Stand the defender one cell east-ish with LOS. const spot = emptyNeighborCell(state, attacker.position); defender.position = spot.cell; state.sustained.push({ id: "fx-test", cardId: "blind", casterId: defender.id, targetId: attacker.id, remainingTurns: 3, data: {}, }); const fb = giveCard(state, attacker.id, "fireball", "F", 0); const result = applyCommand(state, attacker.id, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id }, }); expect(result.ok).toBe(true); if (!result.ok) continue; const dLife = result.state.players.find((p) => p.id === defender.id)!; if (result.state.stack) { hits++; // the roll matched: attack proceeds normally } else { misses++; expect(result.state.turn.attackUsed).toBe(true); // card spent anyway } void dLife; } expect(hits + misses).toBe(10); expect(misses).toBeGreaterThan(0); }); }); describe("ugly", () => { it("drives every visible opponent out of line of sight", () => { let { state } = newGame(); const caster = activePlayer(state); const opp = state.players.find((p) => p.id !== caster.id)!; opp.position = { ...caster.position }; // same square: definitely in LOS const ug = giveCard(state, caster.id, "ugly"); state = must(state, caster.id, { type: "cast", instanceId: ug.instanceId }); const after = state.players.find((p) => p.id === opp.id)!; expect(gameLos(state, state.players.find((p) => p.id === caster.id)!.position, after.position)).toBe(false); }); }); describe("illusion wall", () => { function setupIllusion(seed = 42) { let { state } = newGame(seed); const caster = activePlayer(state); const spot = emptyNeighborCell(state, caster.position); const key = edgeKey(caster.position, spot.side); const iw = giveCard(state, caster.id, "illusion-wall"); state = must(state, caster.id, { type: "cast", instanceId: iw.instanceId, target: { kind: "edge", cell: caster.position, side: spot.side }, }); return { state, caster: caster.id, key, side: spot.side, cell: spot.cell }; } it("the caster walks through their own illusion; others see a wall", () => { const { state, caster, key, side } = setupIllusion(); // Caster's view knows it's fake; the opponent's view shows a wall. const casterView = viewFor(state, caster); expect(casterView.knownIllusionEdges).toContain(key); const other = state.players.find((p) => p.id !== caster)!.id; const otherView = viewFor(state, other); expect(otherView.board.edges[key]).toBe("wall"); // And the caster can move through it freely. const after = applyCommand(state, caster, { type: "move", direction: side }); expect(after.ok).toBe(true); }); it("opponents test the illusion when they bump it — some see through, some believe", () => { let believed = 0, sawThrough = 0; for (let seed = 1; seed <= 12; seed++) { const { state, caster, side } = setupIllusion(seed); const other = state.players.find((p) => p.id !== caster)!; other.position = { ...state.players.find((p) => p.id === caster)!.position }; let s = must(state, caster, { type: "endTurn", draw: 0 }); const result = applyCommand(s, other.id, { type: "move", direction: side }); if (result.ok) sawThrough++; else believed++; } expect(believed + sawThrough).toBe(12); expect(believed).toBeGreaterThan(0); expect(sawThrough).toBeGreaterThan(0); }); it("dispel creation removes an illusion wall", () => { let { state, caster, key, side } = setupIllusion(); const me = state.players.find((p) => p.id === caster)!; const dc = giveCard(state, caster, "dispel-creation", "DC", 1); state = must(state, caster, { type: "cast", instanceId: dc.instanceId, target: { kind: "edge", cell: me.position, side }, }); expect(state.illusionWalls[key]).toBeUndefined(); }); }); describe("sector manipulation", () => { it("rotate sector turns walls and pieces together; the home stays centered", () => { let { state } = newGame(); const me = activePlayer(state); const idx = state.board.placements.findIndex( (p) => me.position.x >= p.origin.x && me.position.x < p.origin.x + 5 && me.position.y >= p.origin.y && me.position.y < p.origin.y + 5, ); const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length; const homeBefore = { ...me.home }; const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! })); const rs = giveCard(state, me.id, "rotate-sector"); state = must(state, me.id, { type: "cast", instanceId: rs.instanceId, target: { kind: "cell", cell: me.position }, params: { clockwise: true }, }); const after = state.players.find((p) => p.id === me.id)!; // Home star is the exact center: rotation cannot move it. expect(cellKey(after.home)).toBe(cellKey(homeBefore)); // The wizard stood on the home (center) at setup? They may have been // anywhere; either way they remain inside the same sector. const p = state.board.placements[idx]!; expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x); expect(after.position.x).toBeLessThan(p.origin.x + 5); // Wall count is invariant under rotation. const wallsAfter = Object.values(boardView(state).edges).filter((e) => e === "wall").length; expect(wallsAfter).toBe(wallsBefore); // Treasures rotated with the sector (diagonal flips to the other diagonal // or stays, but they remain on the board inside the sector). for (const t of state.treasures.filter((t) => t.owner === after.id)) { expect(state.board.cells[cellKey(t.position!)]).toBe(true); } void treasuresBefore; }); it("relocate sector slides everything and keeps adjacency", () => { let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5 const me = activePlayer(state); const idx = state.board.placements.findIndex( (p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5, ); const myOrigin = state.board.placements[idx]!.origin; const otherIdx = idx === 0 ? 1 : 0; const otherOrigin = state.board.placements[otherIdx]!.origin; // Move my sector to the EAST side of the other sector (still adjacent). const dest = { x: otherOrigin.x + 5, y: otherOrigin.y }; const posBefore = { ...me.position }; const rel = giveCard(state, me.id, "relocate-sector"); state = must(state, me.id, { type: "cast", instanceId: rel.instanceId, target: { kind: "cell", cell: dest }, params: { cell: me.position }, }); const after = state.players.find((p) => p.id === me.id)!; const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y; expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy }); expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y })); expect(state.board.placements[idx]!.origin).toEqual(dest); // The map reassembled: every treasure/wizard cell exists on the new board. for (const t of state.treasures) { if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true); } // An island move is refused. const rel2 = giveCard(state, me.id, "relocate-sector", "R2"); const refused = applyCommand(state, me.id, { type: "cast", instanceId: rel2.instanceId, target: { kind: "cell", cell: { x: 40, y: 40 } }, params: { cell: after.position }, }); expect(refused.ok).toBe(false); }); });