import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, createGame, boardView, gameLos, type Command, type GameState, type PlayerId, } from "../src/game"; import { cellKey, 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", "expansion1"] }); } 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 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("expansion terrain", () => { it("killer ooze burns on entry and can drop you on your face", () => { // Across seeds we should see both slips and clean crossings. let slips = 0, crossings = 0; for (let seed = 1; seed <= 10; seed++) { let { state } = newGame(seed); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const oz = giveCard(state, me.id, "killer-ooze"); state = must(state, me.id, { type: "cast", instanceId: oz.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)!; if (p.fallenInOoze) { slips++; expect(p.life).toBe(12); } // 1 + 2 else { crossings++; expect(p.life).toBe(14); } } expect(slips + crossings).toBe(10); expect(slips).toBeGreaterThan(0); expect(crossings).toBeGreaterThan(0); }); it("a pit is jumped on 2-4 and fallen into on a 1", () => { let falls = 0, jumps = 0, teeters = 0; for (let seed = 1; seed <= 12; seed++) { let { state } = newGame(seed); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const pit = giveCard(state, me.id, "create-pit"); state = must(state, me.id, { type: "cast", instanceId: pit.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)!; if (p.inPit) { falls++; expect(p.life).toBe(13); } else if (cellKey(p.position) === cellKey(me.position)) teeters++; else jumps++; } expect(falls + jumps + teeters).toBe(12); expect(falls).toBeGreaterThan(0); }); it("dust cloud blinds anyone standing inside it", () => { let { state } = newGame(); const me = activePlayer(state); const spot = emptyNeighborCell(state, me.position); const dc = giveCard(state, me.id, "dust-cloud"); state = must(state, me.id, { type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: spot.cell }, }); // LOS through the cloud is blocked. const view = boardView(state); const beyond = { x: spot.cell.x + (spot.cell.x - me.position.x), y: spot.cell.y + (spot.cell.y - me.position.y) }; if (view.cells[cellKey(beyond)]) { expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false); } }); it("glue pins a treasure to the floor until it wears off", () => { let { state } = newGame(); const me = activePlayer(state); const treasure = state.treasures.find((t) => t.position && t.owner !== me.id)!; me.position = { ...treasure.position! }; const gl = giveCard(state, me.id, "glue"); giveCard(state, me.id, "number-2", "N", 1); state = must(state, me.id, { type: "cast", instanceId: gl.instanceId, numberInstanceIds: ["number-2#N"], target: { kind: "cell", cell: treasure.position! }, }); expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(false); // 2 x 2 = 4 of the caster's turns later, the glue dries out. for (let i = 0; i < 8; i++) { state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); } state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! }; expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true); }); it("a safe locks a treasure away from everyone but its creator", () => { let { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; const treasure = state.treasures.find((t) => t.position && t.owner === enemy.id)!; me.position = { ...treasure.position! }; const sf = giveCard(state, me.id, "safe"); state = must(state, me.id, { type: "cast", instanceId: sf.instanceId, target: { kind: "cell", cell: treasure.position! }, }); // The enemy cannot take it... state = must(state, me.id, { type: "endTurn", draw: 0 }); const e = state.players.find((p) => p.id === enemy.id)!; e.position = { ...treasure.position! }; expect(applyCommand(state, enemy.id, { type: "pickUpTreasure" }).ok).toBe(false); // ...but the creator knows the combination. state = must(state, enemy.id, { type: "endTurn", draw: 0 }); state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! }; expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true); }); it("boobytrap detonates only under its real token, never under the caster", () => { let { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; // Four distinct empty-ish cells: use home-adjacent floor cells of the board. const view = boardView(state); const open: Cell[] = []; for (const key of Object.keys(view.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; const c = { x, y }; if (state.squareContents[key]) continue; open.push(c); if (open.length === 4) break; } const bt = giveCard(state, me.id, "boobytrap"); state = must(state, me.id, { type: "cast", instanceId: bt.instanceId, params: { cells: open }, }); // Caster strolls across the real token unharmed. const caster = state.players.find((p) => p.id === me.id)!; caster.position = { ...open[0]! }; // (position set directly — trap only triggers on a move; simulate enemy) state = must(state, me.id, { type: "endTurn", draw: 0 }); const e = state.players.find((p) => p.id === enemy.id)!; // Stand the enemy adjacent to the real token and step onto it. for (const side of SIDES) { const from = { x: open[0]!.x + (side === "E" ? -1 : side === "W" ? 1 : 0), y: open[0]!.y + (side === "S" ? -1 : side === "N" ? 1 : 0) }; if (!view.cells[cellKey(from)]) continue; const t = stepTarget(view, from, side); if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) { e.position = from; const result = applyCommand(state, enemy.id, { type: "move", direction: side }); if (result.ok) { state = result.state; const hurt = state.players.find((p) => p.id === enemy.id)!; expect(hurt.life).toBeLessThanOrEqual(11); expect(state.boobytraps.length).toBe(0); } return; } } }); it("stone to water melts a stone block into a crashing wave", () => { 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 }, }); const stw = giveCard(state, me.id, "stone-to-water", "SW", 1); state = must(state, me.id, { type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell }, }); expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined(); // The caster stood beside the block: the wave washed them somewhere (or // crushed them for blocked spaces) — either way life or position changed // is acceptable; assert no crash and the block is gone. }); });