import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src"; import { cellKey, edgeKey, type Cell } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; 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; if (result.state.stack) { hits++; // the roll matched: attack proceeds normally } else { misses++; expect(result.state.turn.attackUsed).toBe(true); // card spent anyway } } 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 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)); // Wherever they stood, rotation keeps them inside the 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); } }); 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 homeBefore = { ...me.home }; 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(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy }); 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); }); }); describe("6e card-face corrections", () => { it("no spell does not block pick lock — 'This is not a spell'", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; defender.position = { ...attacker.position }; const ns = giveCard(state, attacker.id, "no-spell"); giveCard(state, attacker.id, "number-3", "N", 1); state = must(state, attacker.id, { type: "cast", instanceId: ns.instanceId, numberInstanceIds: ["number-3#N"], target: { kind: "player", playerId: defender.id }, }); state = must(state, defender.id, { type: "pass" }); state = must(state, attacker.id, { type: "endTurn", draw: 0 }); // The silenced defender cannot cast a spell... const fb = giveCard(state, defender.id, "fireball", "F", 0); expect(applyCommand(state, defender.id, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker.id }, }).ok).toBe(false); // ...but picking a lock is a physical action, not a spell. const view = boardView(state); const doorEntry = Object.entries(view.edges).find(([, st]) => st === "door")!; const [kind, coords] = doorEntry[0].split(":") as [string, string]; const [dx, dy] = coords.split(",").map(Number) as [number, number]; const d = state.players.find((p) => p.id === defender.id)!; d.position = { x: dx, y: dy }; const pl = giveCard(state, defender.id, "pick-lock", "PL", 1); const result = applyCommand(state, defender.id, { type: "cast", instanceId: pl.instanceId, target: { kind: "edge", cell: { x: dx, y: dy }, side: kind === "V" ? "E" : "S" }, }); expect(result.ok).toBe(true); }); it("blinded punches flail on the die — 'engage in combat'", () => { 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)!; defender.position = { ...attacker.position }; state.sustained.push({ id: "fx-test", cardId: "blind", casterId: defender.id, targetId: attacker.id, remainingTurns: 3, data: {}, }); const result = applyCommand(state, attacker.id, { type: "punch", targetId: defender.id }); expect(result.ok).toBe(true); if (!result.ok) continue; if (result.state.stack) hits++; else { misses++; expect(result.state.turn.attackUsed).toBe(true); // the flail spends the attack } } expect(hits + misses).toBe(10); expect(misses).toBeGreaterThan(0); }); it("wall of fire, as a counteraction, stops a waterbolt cold", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; defender.position = { ...attacker.position }; const wb = giveCard(state, attacker.id, "waterbolt"); giveCard(state, attacker.id, "number-4", "N", 1); giveCard(state, defender.id, "wall-of-fire", "WOF", 0); state = must(state, attacker.id, { type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-4#N"], target: { kind: "player", playerId: defender.id }, params: { damage: 4, knockback: 0 }, }); state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" }); state = must(state, attacker.id, { type: "pass" }); state = must(state, defender.id, { type: "pass" }); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15); // But it cannot counter a fireball. state = must(state, attacker.id, { type: "endTurn", draw: 0 }); state = must(state, defender.id, { type: "endTurn", draw: 0 }); const fb = giveCard(state, attacker.id, "fireball", "F", 0); giveCard(state, defender.id, "wall-of-fire", "WOF2", 0); state = must(state, attacker.id, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id }, }); expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false); }); });