import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game"; import { cellKey, edgeKey, neighbor, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; describe("duration spells", () => { it("slow reduces movement to 1, blocks number cards, and halves attacks", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const slow = giveCard(state, attacker, "slow"); giveCard(state, attacker, "number-4", "N", 1); state = castAt(state, attacker, defender, slow, { numberInstanceIds: ["number-4#N"] }); expect(sustainedOn(state, defender, "slow").length).toBe(1); // Defender's turn: 1 movement, no number cards, no attack (1st slowed turn). state = must(state, attacker, { type: "endTurn", draw: 0 }); expect(activePlayer(state).id).toBe(defender); expect(state.turn.movementAllowance).toBe(1); expect(state.turn.attackForbidden).toBe(true); const num = giveCard(state, defender, "number-3", "M", 0); expect(applyCommand(state, defender, { type: "playNumberForMovement", instanceId: num.instanceId }).ok).toBe(false); // Second slowed turn: attack allowed again. state = must(state, defender, { type: "endTurn", draw: 0 }); state = must(state, attacker, { type: "endTurn", draw: 0 }); expect(activePlayer(state).id).toBe(defender); expect(state.turn.attackForbidden).toBe(false); }); it("duration spells expire at the start of the caster's turn", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const ns = giveCard(state, attacker, "no-spell"); // Duration 1 (no number card). state = castAt(state, attacker, defender, ns); expect(sustainedOn(state, defender, "no-spell").length).toBe(1); // Defender cannot cast while it lasts. const fb = giveCard(state, defender, "fireball", "F", 1); state = must(state, attacker, { type: "endTurn", draw: 0 }); const refused = applyCommand(state, defender, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker }, }); expect(refused.ok).toBe(false); // Back to the caster: the spell expires at their turn start. state = must(state, defender, { type: "endTurn", draw: 0 }); expect(sustainedOn(state, defender, "no-spell").length).toBe(0); }); it("medusa paralyzes and grants damage immunity", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const med = giveCard(state, attacker, "medusa"); giveCard(state, attacker, "number-2", "N", 1); // Duration 2 so it survives past the caster's next turn start. state = castAt(state, attacker, defender, med, { numberInstanceIds: ["number-2#N"] }); // Defender is immune to damage while paralyzed. state = must(state, attacker, { type: "endTurn", draw: 0 }); expect(activePlayer(state).id).toBe(defender); expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false); state = must(state, defender, { type: "endTurn", draw: 0 }); // Attacker punches the frozen defender: no damage. state = must(state, attacker, { type: "punch", targetId: defender }); // Defender cannot counteract under medusa; they pass. state = must(state, defender, { type: "pass" }); expect(state.players.find((p) => p.id === defender)!.life).toBe(15); }); it("invisible makes attacks miss 3 times out of 4 (deterministic per seed)", () => { let hits = 0, misses = 0; for (let seed = 1; seed <= 12; seed++) { let { state } = newGame(seed); state = toRound2(state); const { attacker, defender } = faceOff(state); const inv = giveCard(state, defender, "invisible", "I", 0); // Defender casts invisible on their own turn first — rearrange: give // attacker the attack, let defender cast invisible when active. // Simpler: attach directly via a cast from the defender's turn. state.players.find((p) => p.id === defender)!.hand[0] = inv; // Attacker ends turn; defender casts invisible; attacker attacks. state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "cast", instanceId: inv.instanceId }); state = must(state, defender, { type: "endTurn", draw: 0 }); const fb = giveCard(state, attacker, "fireball", "F", 0); state = castAt(state, attacker, defender, fb); const life = state.players.find((p) => p.id === defender)!.life; if (life < 15) hits++; else misses++; } expect(hits + misses).toBe(12); expect(misses).toBeGreaterThan(hits); // 75% miss rate over 12 seeds }); }); describe("doors", () => { function findDoor(state: GameState): { cell: { x: number; y: number }; side: Side } { const view = boardView(state); for (const [key, edgeState] of Object.entries(view.edges)) { if (edgeState !== "door") continue; const [kind, coords] = key.split(":") as [string, string]; const [x, y] = coords.split(",").map(Number) as [number, number]; return kind === "V" ? { cell: { x, y }, side: "E" } : { cell: { x, y }, side: "S" }; } throw new Error("no door on this board"); } it("pick lock opens an adjacent door until end of turn", () => { let { state } = newGame(); const me = activePlayer(state); const door = findDoor(state); me.position = { ...door.cell }; const other = neighbor(door.cell, door.side); const dir = door.side; // Locked: blocked. expect(applyCommand(state, me.id, { type: "move", direction: dir }).ok).toBe(false); const pl = giveCard(state, me.id, "pick-lock"); state = must(state, me.id, { type: "cast", instanceId: pl.instanceId, target: { kind: "edge", cell: door.cell, side: door.side }, }); state = must(state, me.id, { type: "move", direction: dir }); expect(cellKey(activePlayer(state).position)).toBe(cellKey(other)); // Relocks when the turn ends. state = must(state, me.id, { type: "endTurn", draw: 0 }); state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); expect(state.openDoorEdges.length).toBe(0); expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false); }); it("remove lock is permanent; jam lock seals the door for everyone", () => { let { state } = newGame(); const me = activePlayer(state); const door = findDoor(state); me.position = { ...door.cell }; const key = edgeKey(door.cell, door.side); const rl = giveCard(state, me.id, "remove-lock"); state = must(state, me.id, { type: "cast", instanceId: rl.instanceId, target: { kind: "edge", cell: door.cell, side: door.side }, }); expect(state.doorStates[key]).toBe("removed"); state = must(state, me.id, { type: "move", direction: door.side }); // Jamming a removed lock is refused. const jl = giveCard(state, me.id, "jam-lock"); const refused = applyCommand(state, me.id, { type: "cast", instanceId: jl.instanceId, target: { kind: "edge", cell: door.cell, side: door.side }, }); expect(refused.ok).toBe(false); }); }); describe("movement spells", () => { it("teleport jumps up to four spaces through walls and ends movement", () => { let { state } = newGame(); const me = activePlayer(state); const from = me.position; const tp = giveCard(state, me.id, "teleport"); const far = { x: from.x, y: from.y >= 4 ? from.y - 4 : from.y + 4 }; state = must(state, me.id, { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: far }, }); expect(cellKey(activePlayer(state).position)).toBe(cellKey(far)); expect(applyCommand(state, me.id, { type: "move", direction: "N" }).ok).toBe(false); }); it("teleport refuses jumps beyond four spaces", () => { const { state } = newGame(); const me = activePlayer(state); const tp = giveCard(state, me.id, "teleport"); const tooFar = { x: me.position.x, y: me.position.y >= 5 ? me.position.y - 5 : me.position.y + 5 }; const result = applyCommand(state, me.id, { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: tooFar }, }); expect(result.ok).toBe(false); }); it("swap trades places and consumes movement", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; const aPos = { ...attacker.position }; const bPos = { ...defender.position }; const sw = giveCard(state, attacker.id, "swap"); state = castAt(state, attacker.id, defender.id, sw); expect(cellKey(state.players.find((p) => p.id === attacker.id)!.position)).toBe(cellKey(bPos)); expect(cellKey(state.players.find((p) => p.id === defender.id)!.position)).toBe(cellKey(aPos)); expect(state.turn.movementUsed).toBe(state.turn.movementAllowance); }); it("power run trades life for movement", () => { let { state } = newGame(); const me = activePlayer(state); const pr = giveCard(state, me.id, "power-run"); state = must(state, me.id, { type: "cast", instanceId: pr.instanceId, params: { points: 3 }, }); expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); expect(state.turn.movementAllowance).toBe(6); }); it("pass through wall grants a one-wall step", () => { let { state } = newGame(); const me = activePlayer(state); // Find a direction blocked by a wall with a real cell behind it. const view = boardView(state); let dir: Side | null = null; for (const side of ["N", "S", "E", "W"] as Side[]) { const k = edgeKey(me.position, side); if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(me.position, side))]) { dir = side; break; } } if (!dir) throw new Error("setup: seed 42 lost its adjacent wall"); const ptw = giveCard(state, me.id, "pass-through-wall"); state = must(state, me.id, { type: "cast", instanceId: ptw.instanceId }); state = must(state, me.id, { type: "move", direction: dir }); expect(state.players.find((p) => p.id === me.id)!.passWallCharges).toBe(0); }); }); describe("card warfare", () => { it("card erasure discards a named card; thought steal takes two at random", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); giveCard(state, defender, "fireball", "V", 0); const ce = giveCard(state, attacker, "card-erasure"); state = castAt(state, attacker, defender, ce, { params: { cardId: "fireball" } }); const d = state.players.find((p) => p.id === defender)!; expect(d.hand.some((c) => c.cardId === "fireball")).toBe(false); expect(d.hand.length).toBe(6); state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "endTurn", draw: 0 }); const ts = giveCard(state, attacker, "thought-steal"); const handBefore = state.players.find((p) => p.id === attacker)!.hand.length; state = castAt(state, attacker, defender, ts); // -1 (thought steal cast) +2 stolen expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(handBefore + 1); expect(state.players.find((p) => p.id === defender)!.hand.length).toBe(4); }); it("power drain transfers life; sudden death does 10", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const pd = giveCard(state, attacker, "power-drain"); giveCard(state, attacker, "number-4", "N", 1); state = castAt(state, attacker, defender, pd, { numberInstanceIds: ["number-4#N"] }); expect(state.players.find((p) => p.id === defender)!.life).toBe(11); expect(state.players.find((p) => p.id === attacker)!.life).toBe(19); state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "endTurn", draw: 0 }); const sd = giveCard(state, attacker, "sudden-death"); state = castAt(state, attacker, defender, sd); expect(state.players.find((p) => p.id === defender)!.life).toBe(1); }); it("wizardblade needs the same square, uses a number card, and stays displayed", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; const wb = giveCard(state, attacker.id, "wizardblade"); giveCard(state, attacker.id, "number-3", "N", 1); // Not same square: refused. defender.position = { x: attacker.position.x, y: attacker.position.y === 0 ? 1 : attacker.position.y - 1 }; const refused = applyCommand(state, attacker.id, { type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-3#N"], target: { kind: "player", playerId: defender.id }, }); expect(refused.ok).toBe(false); defender.position = { ...attacker.position }; state = castAt(state, attacker.id, defender.id, wb, { numberInstanceIds: ["number-3#N"] }); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12); const a = state.players.find((p) => p.id === attacker.id)!; expect(a.hand.some((c) => c.cardId === "wizardblade")).toBe(true); expect(a.displayed).toContain(wb.instanceId); }); }); describe("cast modifiers", () => { it("amplify doubles fireball; add joins two number cards", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const fb = giveCard(state, attacker, "fireball"); giveCard(state, attacker, "amplify", "A", 1); state = castAt(state, attacker, defender, fb, { amplifyInstanceIds: ["amplify#A"] }); expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5 x2 state = must(state, attacker, { type: "endTurn", draw: 0 }); state = must(state, defender, { type: "endTurn", draw: 0 }); const lb = giveCard(state, attacker, "lightning-blast"); giveCard(state, attacker, "number-2", "N1", 1); giveCard(state, attacker, "number-3", "N2", 2); giveCard(state, attacker, "add", "AD", 3); // Two numbers without ADD: refused. const refused = applyCommand(state, attacker, { type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-2#N1", "number-3#N2"], target: { kind: "player", playerId: defender }, }); expect(refused.ok).toBe(false); state = castAt(state, attacker, defender, lb, { numberInstanceIds: ["number-2#N1", "number-3#N2"], addInstanceId: "add#AD", }); expect(state.players.find((p) => p.id === defender)!.life).toBe(0); // 5 dmg on 5 life }); it("reverse turns damage into healing but keeps secondary effects", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const lb = giveCard(state, attacker, "lightning-blast"); giveCard(state, attacker, "number-4", "N", 1); giveCard(state, defender, "reverse", "R", 0); state = must(state, attacker, { type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-4#N"], target: { kind: "player", playerId: defender }, }); state = must(state, defender, { type: "counteract", instanceId: "reverse#R" }); 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(19); // gained 4 instead of losing it expect(d.lostTurns).toBe(1); // the stun still applies }); }); describe("holding the door open (Pick Lock / Master Key)", () => { function doorRig() { let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 }); state = toRound2(state); // Find a door edge; stand the acting player beside it. const view = boardView(state); for (const [key, edge] of Object.entries(view.edges)) { if (edge !== "door") continue; const [kind, coords] = key.split(":") as [string, string]; const [x, y] = coords.split(",").map(Number) as [number, number]; const cell = { x, y }; const side = kind === "V" ? ("E" as Side) : ("S" as Side); const holder = activePlayer(state); holder.position = { ...cell }; return { state, key, cell, side, holder: holder.id }; } throw new Error("setup: seed 42 grew a maze with no doors"); } it("a held door outlives the turn and admits another wizard", () => { let { state, key, cell, side, holder } = doorRig(); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, target: { kind: "edge", cell, side }, params: { hold: true }, }); state = must(state, holder, { type: "endTurn", draw: 0 }); expect(state.heldDoors.some((h) => h.key === key)).toBe(true); const guest = state.players.find((p) => p.id !== holder)!; guest.position = { ...cell }; const r = applyCommand(state, guest.id, { type: "move", direction: side }); if (!r.ok) throw new Error(r.error); const through = r.state.players.find((p) => p.id === guest.id)!; expect(cellKey(through.position)).toBe(cellKey(neighbor(cell, side))); }); it("the door swings shut the moment the holder steps away", () => { let { state, key, cell, side, holder } = doorRig(); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, target: { kind: "edge", cell, side }, params: { hold: true }, }); expect(state.heldDoors.some((h) => h.key === key)).toBe(true); // March the holder until adjacency breaks; the sweep must release. let walked = state; let released = false; const dirs: Side[] = ["N", "E", "S", "W"]; for (const d1 of dirs) { const r1 = applyCommand(walked, holder, { type: "move", direction: d1 }); if (!r1.ok) continue; if (!r1.state.heldDoors.some((h) => h.key === key)) { released = true; break; } for (const d2 of dirs) { const r2 = applyCommand(r1.state, holder, { type: "move", direction: d2 }); if (!r2.ok) continue; if (!r2.state.heldDoors.some((h) => h.key === key)) { released = true; break; } } if (released) break; } expect(released).toBe(true); }); }); describe("a held door is an open doorway to the eye (rules rev 15)", () => { function sightRig(deckRev: number) { let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev }); state = toRound2(state); const view = boardView(state); for (const [key, edge] of Object.entries(view.edges)) { if (edge !== "door") continue; const [kind, coords] = key.split(":") as [string, string]; const [x, y] = coords.split(",").map(Number) as [number, number]; const cell = { x, y }; const side = kind === "V" ? ("E" as Side) : ("S" as Side); const holder = activePlayer(state); const pursuer = state.players.find((p) => p.id !== holder.id)!; holder.position = { ...cell }; pursuer.position = neighbor(cell, side); return { state, cell, side, holder: holder.id, pursuer: pursuer.id }; } throw new Error("setup: seed 42 grew a maze with no doors"); } it("rev 15: the holder blasts the pursuer through the doorway", () => { let { state, cell, side, holder, pursuer } = sightRig(15); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, target: { kind: "edge", cell, side }, params: { hold: true }, }); const fb = giveCard(state, holder, "fireball", "FB", 1); const lifeBefore = state.players.find((p) => p.id === pursuer)!.life; state = must(state, holder, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, }); state = must(state, pursuer, { type: "pass" }); expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore); }); it("an unheld unlocked door still blocks sight", () => { let { state, cell, side, holder, pursuer } = sightRig(15); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, target: { kind: "edge", cell, side }, }); const fb = giveCard(state, holder, "fireball", "FB", 1); const refused = applyCommand(state, holder, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, }); expect(refused.ok).toBe(false); }); it("earlier revisions keep the blocked sightline, hold or no", () => { let { state, cell, side, holder, pursuer } = sightRig(14); const pick = giveCard(state, holder, "pick-lock"); state = must(state, holder, { type: "cast", instanceId: pick.instanceId, target: { kind: "edge", cell, side }, params: { hold: true }, }); const fb = giveCard(state, holder, "fireball", "FB", 1); const refused = applyCommand(state, holder, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer }, }); expect(refused.ok).toBe(false); }); });