import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view"; import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers"; /** The sides a walker may leave a pit by, having entered it heading `entry`. */ function rimExits(state: GameState, pit: Cell, entry: Side): Side[] { const view = boardView(state); return SIDES.filter((d) => { if (d === opposite(entry)) return false; const b = neighbor(pit, d); return !!view.cells[cellKey(b)] && (view.edges[edgeKey(pit, d)] ?? "open") === "open" && state.squareContents[cellKey(b)]?.kind !== "stone"; }); } /** Two pit sites on a board: a fork (several ways off) and a corridor (one * way off, straight ahead), each with the square a walker enters from. */ function pitSpots(state: GameState) { const view = boardView(state); let fork: { from: Cell; pit: Cell; side: Side; exits: Side[] } | null = null; let corridor: { from: Cell; pit: Cell; side: Side; beyond: Cell } | null = null; for (const k of Object.keys(view.cells)) { const [x, y] = k.split(",").map(Number) as [number, number]; for (const side of SIDES) { const t = stepTarget(view, { x, y }, side); if (t.kind !== "step") continue; if (state.squareContents[k] || state.squareContents[cellKey(t.to)]) continue; if (view.homes.some((h) => cellKey(h) === k || cellKey(h) === cellKey(t.to))) continue; const exits = rimExits(state, t.to, side); if (!fork && exits.length >= 2) fork = { from: { x, y }, pit: t.to, side, exits }; if (!corridor && exits.length === 1 && exits[0] === side) corridor = { from: { x, y }, pit: t.to, side, beyond: neighbor(t.to, side) }; if (fork && corridor) return { fork, corridor }; } } throw new Error("board lacks a fork or a corridor pit site"); } 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 }, }); // Rev 14: the rim's exits are the walker's to name; take the first. const exits = rimExits(state, spot.cell, spot.side); if (exits.length === 0) continue; state = must(state, me.id, { type: "move", direction: spot.side, exit: exits[0] }); 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(teeters).toBe(0); expect(falls).toBeGreaterThan(0); expect(jumps).toBeGreaterThan(0); }); it("the rim leads off a pit: one way is taken, several must be named, none cannot be entered", () => { const found = pitSpots(newGame(42).state); // Several ways off: a bare step is refused with the choices; a named exit lands there or falls in. { let { state } = newGame(42); const me = activePlayer(state); me.position = { ...found.fork.from }; state.squareContents[cellKey(found.fork.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; const bare = applyCommand(state, me.id, { type: "move", direction: found.fork.side }); expect(bare.ok).toBe(false); if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/); const chosen = found.fork.exits[1]!; const named = applyCommand(state, me.id, { type: "move", direction: found.fork.side, exit: chosen }); expect(named.ok).toBe(true); if (named.ok) { const p = named.state.players.find((p) => p.id === me.id)!; const landing = neighbor(found.fork.pit, chosen); expect(p.inPit || cellKey(p.position) === cellKey(landing)).toBe(true); } } // No way off: refused before any die is rolled (rev 14); bounced and charged in an older game. for (const deckRev of [14, 13]) { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); const me = activePlayer(state); me.position = { ...found.corridor.from }; state.squareContents[cellKey(found.corridor.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; state.squareContents[cellKey(found.corridor.beyond)] = { kind: "stone", damage: 0, createdBy: me.id }; const r = applyCommand(state, me.id, { type: "move", direction: found.corridor.side }); if (deckRev >= 14) { expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/cannot be crossed/); } else { expect(r.ok).toBe(true); if (r.ok) { const p = r.state.players.find((p) => p.id === me.id)!; if (!p.inPit) { expect(cellKey(p.position)).toBe(cellKey(found.corridor.from)); expect(r.events.some((e) => e.type === "moveBumped")).toBe(true); } } } } }); 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; state = must(state, enemy.id, { type: "move", direction: side }); const hurt = state.players.find((p) => p.id === enemy.id)!; expect(hurt.life).toBeLessThanOrEqual(11); expect(state.boobytraps.length).toBe(0); return; } } throw new Error("setup: seed 42 offers no approach to the trap"); }); 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(); // Wave side effects vary by geometry; the melt itself is the pinned behavior. }); it("wall of fire takes a rim warp — both mouths burn the crossing", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const warp = state.board.warps[0]!; me.position = { ...warp.from.cell }; const wof = giveCard(state, me.id, "wall-of-fire", "WF", 0); const r = applyCommand(state, me.id, { type: "cast", instanceId: wof.instanceId, target: { kind: "edge", cell: warp.from.cell, side: warp.from.side }, }); if (!r.ok) throw new Error(r.error); state = r.state; const nearKey = edgeKey(warp.from.cell, warp.from.side); const farKey = edgeKey(warp.to.cell, warp.to.side); expect(boardView(state).edges[nearKey]).toBe("firewall"); expect(boardView(state).edges[farKey]).toBe("firewall"); // The corridor still runs — through flame: the crossing lands on the // far rim and burns for 4. state = must(state, me.id, { type: "move", direction: warp.from.side }); const after = state.players.find((p) => p.id === me.id)!; expect(cellKey(after.position)).toBe(cellKey(warp.to.cell)); expect(after.life).toBe(11); }); it("waterwall takes a rim warp — the collapse washes both rims", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const other = state.players.find((p) => p.id !== me.id)!; const warp = state.board.warps[0]!; me.position = { ...warp.from.cell }; other.position = { ...warp.to.cell }; const ww = giveCard(state, me.id, "waterwall", "WW", 0); const r = applyCommand(state, me.id, { type: "cast", instanceId: ww.instanceId, target: { kind: "edge", cell: warp.from.cell, side: warp.from.side }, }); if (!r.ok) throw new Error(r.error); // Both wizards stood in the mouths; the collapse washed both inward. const meAfter = r.state.players.find((p) => p.id === me.id)!; const otherAfter = r.state.players.find((p) => p.id === other.id)!; expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell)); expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell)); }); it("illusion wall hangs on a rim warp mouth", () => { const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const warp = state.board.warps[0]!; me.position = { ...warp.from.cell }; const il = giveCard(state, me.id, "illusion-wall", "IL", 0); const r = applyCommand(state, me.id, { type: "cast", instanceId: il.instanceId, target: { kind: "edge", cell: warp.from.cell, side: warp.from.side }, }); expect(r.ok).toBe(true); if (r.ok) { expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined(); } }); it("stone to water breaches the outer rim, opening a warp", () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const rim = plainRimWall(state); me.position = { ...rim.cell }; const warpsBefore = state.board.warps.length; const stw = giveCard(state, me.id, "stone-to-water", "SW", 0); state = must(state, me.id, { type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: rim.cell, side: rim.side }, }); // The far rim melted with it and the wraparound now runs. expect(state.board.warps.length).toBe(warpsBefore + 2); }); it("no doors through the rim", () => { const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const rim = plainRimWall(state); me.position = { ...rim.cell }; const cd = giveCard(state, me.id, "create-door", "CD", 0); const r = applyCommand(state, me.id, { type: "cast", instanceId: cd.instanceId, target: { kind: "edge", cell: rim.cell, side: rim.side }, }); expect(r.ok).toBe(false); }); it("stone to water melts a door — a small entryway in a stone wall", () => { const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], }); const me = activePlayer(state); const board = boardView(state); // Stand the caster at some door and melt it point-blank. let doorAt: { cell: Cell; side: Side } | null = null; outer: for (const k of Object.keys(board.cells)) { const [x, y] = k.split(",").map(Number) as [number, number]; for (const side of SIDES) { if (board.edges[edgeKey({ x, y }, side)] === "door") { doorAt = { cell: { x, y }, side }; break outer; } } } if (!doorAt) throw new Error("setup: no door on this board"); me.position = { ...doorAt.cell }; const stw = giveCard(state, me.id, "stone-to-water", "SW", 0); const r = applyCommand(state, me.id, { type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: doorAt.cell, side: doorAt.side }, }); expect(r.ok).toBe(true); if (r.ok) { const key = edgeKey(doorAt.cell, doorAt.side); expect(boardView(r.state).edges[key] ?? "open").toBe("open"); expect(r.state.doorStates[key]).toBeUndefined(); } }); }); describe("eligibility dimming mirrors the engine", () => { it("boobytrap tokens go anywhere but solid stone, sight be damned", () => { let { state } = newGame(); const caster = activePlayer(state).id; const view = viewFor(state, caster); const lit = eligibleCellsFor(view, "boobytrap")!; // Every board square is fair game (the fresh maze holds no solid stone), // including squares far outside the caster's sight. expect(lit.size).toBe(Object.keys(view.board.cells).length); }); it("glue lights only sighted squares that hold something", () => { let { state } = newGame(); const caster = activePlayer(state); // Drop a dagger at the caster's feet — the one guaranteed-sighted object. const here = { ...caster.position }; state.groundObjects[cellKey(here)] = [{ instanceId: "dagger#G", cardId: "dagger" }]; const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!; expect(lit.has(cellKey(here))).toBe(true); // Empty squares stay dim — glue needs something to glue down. const view = viewFor(state, caster.id); for (const k of lit) { const held = (view.groundObjects[k] ?? []).length > 0 || view.treasures.some((t) => t.position && cellKey(t.position) === k); expect(held).toBe(true); } }); it("dispel dimming lights creatures and demands sight, whoever made them", () => { let { state } = newGame(); const caster = activePlayer(state); const other = state.players.find((p) => p.id !== caster.id)!; // An enemy wraith beside the caster: created by the OTHER wizard. state.creatures.push({ id: "w1", kind: "wraith", controllerId: other.id, position: { x: caster.position.x, y: caster.position.y }, damage: 0, maxDamage: Infinity, movesPerTurn: 3, movementUsed: 0, attackUsed: false, justCreated: false, wallPassesPerTurn: 1, wallPassUsed: 0, scorchedThisTurn: [], }); const lit = eligibleCellsFor(viewFor(state, caster.id), "dispel-creation")!; expect(lit.has(cellKey(caster.position))).toBe(true); // Everything lit is created AND sighted. const view = viewFor(state, caster.id); const seen = sightedCellsFor(view); for (const k of lit) expect(seen.has(k)).toBe(true); }); }); describe("a wave's force is spent as it travels", () => { /** Caster at B, one cell above A; the target wall is A's south edge, so * the range-2 wave covers A (dist 0) and B (dist 1). */ function rig(behind: "open" | "wall") { const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); const me = activePlayer(state); const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 }; for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy(); state.edgeOverrides[edgeKey(A, "S")] = "wall"; state.edgeOverrides[edgeKey(A, "N")] = "open"; state.edgeOverrides[edgeKey(B, "N")] = behind; state.edgeOverrides[edgeKey(Bn, "N")] = "open"; me.position = { ...B }; // The other wizard waits far outside the wave. state.players.find((p) => p.id !== me.id)!.position = { x: 0, y: 9 }; const stw = giveCard(state, me.id, "stone-to-water", "SW", 0); const after = must(state, me.id, { type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: A, side: "S" }, }); return { me: after.players.find((p) => p.id === me.id)!, B, Bn, Bnn }; } it("a victim at the wave's far edge is carried one space, unhurt", () => { const { me, Bn } = rig("open"); expect(cellKey(me.position)).toBe(cellKey(Bn)); expect(me.life).toBe(15); }); it("only unspent force crushes: one space of push blocked is one damage", () => { const { me, B } = rig("wall"); expect(cellKey(me.position)).toBe(cellKey(B)); expect(me.life).toBe(14); }); }); describe("boobytrap decoys die their own deaths", () => { it("a stepped-on blank token vanishes alone; the real one still waits", () => { let { state } = newGame(); const me = activePlayer(state); const enemy = state.players.find((p) => p.id !== me.id)!; 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]; if (state.squareContents[key]) continue; open.push({ x, y }); 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 }, }); state = must(state, me.id, { type: "endTurn", draw: 0 }); // The enemy steps onto a BLANK (open[1] — the real trap is open[0]). const e = state.players.find((p) => p.id === enemy.id)!; const blank = open[1]!; for (const side of SIDES) { const from = { x: blank.x + (side === "E" ? -1 : side === "W" ? 1 : 0), y: blank.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(blank)) { e.position = from; const before = e.life; const r = applyCommand(state, enemy.id, { type: "move", direction: side }); if (!r.ok) throw new Error(r.error); state = r.state; expect(r.events.some((ev) => ev.type === "boobytrapBlank")).toBe(true); const after = state.players.find((p) => p.id === enemy.id)!; expect(after.life).toBe(before); const trap = state.boobytraps[0]!; expect(trap.cells.length).toBe(3); expect(trap.cells.some((c) => cellKey(c) === cellKey(blank))).toBe(false); expect(trap.realKey).toBe(cellKey(open[0]!)); return; } } throw new Error("setup: no approach to the blank token"); }); }); describe("the boobytrap keeps its secret", () => { it("stored and broadcast cells are canonically ordered — position tells nothing", () => { let { state } = newGame(); const me = activePlayer(state); 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]; if (state.squareContents[key]) continue; open.push({ x, y }); if (open.length === 4) break; } // Cast with the REAL trap deliberately last-sorting: reverse order. const reversed = [...open].reverse(); const bt = giveCard(state, me.id, "boobytrap"); const r = applyCommand(state, me.id, { type: "cast", instanceId: bt.instanceId, params: { cells: reversed }, }); if (!r.ok) throw new Error(r.error); const trap = r.state.boobytraps[0]!; const keys = trap.cells.map((c) => cellKey(c)); expect([...keys].sort()).toEqual(keys); // canonical order, not casting order expect(trap.realKey).toBe(cellKey(reversed[0]!)); // the truth survives aside const placed = r.events.find((e) => e.type === "boobytrapPlaced"); if (placed?.type === "boobytrapPlaced") { const evKeys = placed.cells.map((c) => cellKey(c)); expect([...evKeys].sort()).toEqual(evKeys); } }); }); describe("a pit on the board's rim (M4Q7)", () => { /** A square on the outer rim with a warp mouth on one side, entered from * the square opposite the mouth; its other neighbouring squares listed. */ function rimPitSite(state: GameState) { const view = boardView(state); for (const k of Object.keys(view.cells)) { const [x, y] = k.split(",").map(Number) as [number, number]; const pit = { x, y }; if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue; for (const mouth of SIDES) { if (stepTarget(view, pit, mouth).kind !== "warp") continue; const entry = stepTarget(view, pit, opposite(mouth)); if (entry.kind !== "step" || state.squareContents[cellKey(entry.to)]) continue; const floor = SIDES.filter((d) => d !== mouth && d !== opposite(mouth) && stepTarget(view, pit, d).kind === "step"); if (floor.length === 0) continue; return { pit, mouth, from: entry.to, floor, far: stepTarget(view, pit, mouth) as { kind: "warp"; to: Cell } }; } } throw new Error("no rim pit site on this board"); } const stoneUp = (state: GameState, pit: Cell, sides: Side[], by: string) => { for (const d of sides) state.squareContents[cellKey(neighbor(pit, d))] = { kind: "stone", damage: 0, createdBy: by }; }; for (const deckRev of [17, 16]) { it(`walls either side, the warp mouth is the way off (rev ${deckRev})`, () => { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); const site = rimPitSite(state); const me = activePlayer(state); me.position = { ...site.from }; state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; stoneUp(state, site.pit, site.floor, me.id); const r = applyCommand(state, me.id, { type: "move", direction: site.mouth }); expect(r.ok).toBe(true); if (!r.ok) return; const p = r.state.players.find((p) => p.id === me.id)!; if (p.inPit) return; expect(cellKey(p.position)).toBe(cellKey(site.far.to)); expect(r.events.some((e) => e.type === "jumpedPit" && e.via === "warp")).toBe(true); }); } it("a square beside the mouth: rev 17 asks which, rev 16 takes the square", () => { for (const deckRev of [17, 16]) { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); const site = rimPitSite(state); const me = activePlayer(state); me.position = { ...site.from }; state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; stoneUp(state, site.pit, site.floor.slice(1), me.id); const bare = applyCommand(state, me.id, { type: "move", direction: site.mouth }); if (deckRev >= 17) { expect(bare.ok).toBe(false); if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/); const named = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth }); expect(named.ok).toBe(true); if (named.ok) { const p = named.state.players.find((p) => p.id === me.id)!; expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true); } } else { expect(bare.ok).toBe(true); if (bare.ok) { const p = bare.state.players.find((p) => p.id === me.id)!; expect(p.inPit || cellKey(p.position) === cellKey(neighbor(site.pit, site.floor[0]!))).toBe(true); } const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth }); expect(mouth.ok).toBe(false); } } }); });