diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts index 90d98c3..e3f89c1 100644 --- a/packages/engine/src/board.ts +++ b/packages/engine/src/board.ts @@ -372,6 +372,9 @@ export function hasWarpLineOfSight( // A filled mouth chokes the tunnel unless the viewer/target IS the mouth. if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue; if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue; + // A wall built over either mouth seals the tunnel to sight entirely. + if (edgeState(board, mouthA, sideA) !== "open") continue; + if (edgeState(board, mouthB, w.to.side) !== "open") continue; // Rotation taking the far board's inward direction onto sideA. const u = DIR[inward], v = DIR[sideA]; diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a2e7ef3..5b55e35 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -744,6 +744,16 @@ interface DamagePipeline { kind: "spell" | "physical"; } +/** The far mouth of the warp whose near mouth is this edge, if any. */ +function pairedWarpMouth( + board: AssembledBoard, cell: Cell, side: Side, +): { cell: Cell; side: Side } | null { + const w = board.warps.find( + (w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side, + ); + return w ? { cell: w.to.cell, side: w.to.side } : null; +} + const CARD_EFFECTS: Record = { // --- Attacks: damage ------------------------------------------------------ fireball: { @@ -979,7 +989,12 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "edge") return "create-wall targets a wall edge"; const { cell, side } = cmd.target; const view = boardView(state); - if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { + // A board-edge warp mouth counts as a wallable line: bricking over + // the opening is a classic play, and the far "space" is the tunnel. + const isWarpMouth = view.warps.some( + (w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side, + ); + if (!view.cells[cellKey(cell)] || (!view.cells[cellKey(neighbor(cell, side))] && !isWarpMouth)) { return "walls must be created between two spaces on the board"; } const key = edgeKey(cell, side); @@ -988,6 +1003,14 @@ const CARD_EFFECTS: Record state.edgeOverrides[key] = "wall"; state.createdEdges[key] = true; events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); + // A warp is ONE tunnel: bricking a mouth walls both ends. + const pair = pairedWarpMouth(view, cell, side); + if (pair) { + const pkey = edgeKey(pair.cell, pair.side); + state.edgeOverrides[pkey] = "wall"; + state.createdEdges[pkey] = true; + events.push({ type: "wallCreated", caster: caster.id, edge: pair }); + } return null; }, }, @@ -1005,6 +1028,13 @@ const CARD_EFFECTS: Record delete state.doorStates[key]; delete state.createdEdges[key]; events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" }); + const pair = pairedWarpMouth(view, cell, side); + if (pair && state.createdEdges[edgeKey(pair.cell, pair.side)]) { + const pkey = edgeKey(pair.cell, pair.side); + state.edgeOverrides[pkey] = "open"; + delete state.createdEdges[pkey]; + events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false }); + } for (const c of [cell, neighbor(cell, side)]) { for (const p of state.players) { if (p.alive && cellKey(p.position) === cellKey(c)) { diff --git a/packages/engine/test/board.test.ts b/packages/engine/test/board.test.ts index afd7d8d..0c5f93e 100644 --- a/packages/engine/test/board.test.ts +++ b/packages/engine/test/board.test.ts @@ -9,7 +9,7 @@ import { type SectorPlacement, } from "../src/board"; import { buildDeck } from "../src/cards"; -import { activePlayer, applyCommand, createGame } from "../src/game"; +import { activePlayer, applyCommand, boardView, createGame, gameLos } from "../src/game"; import { createRng } from "../src/rng"; import { setupBoard } from "../src/setups"; import { giveCard } from "./helpers"; @@ -240,3 +240,42 @@ describe("the aisle warp treats its corner as adjacent — sight included", () = expect(offAxis).toBeGreaterThan(0); }); }); + +describe("bricking over a warp mouth", () => { + it("create wall seals a lettered opening: no passage, no sight", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 }); + // round 1 passes + for (let i = 0; i < 2; i++) { + const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const w = state.board.warps[0]!; + const caster = activePlayer(state); + caster.position = { ...w.from.cell }; + caster.hand[0] = { instanceId: "cw#1", cardId: "create-wall" }; + const r = applyCommand(state, caster.id, { + type: "cast", instanceId: "cw#1", + target: { kind: "edge", cell: w.from.cell, side: w.from.side }, + }); + if (!r.ok) throw new Error(r.error); + state = r.state; + // Passage: stepping into the warp is now a wall. + const step = stepTarget(boardView(state), w.from.cell, w.from.side); + expect(step.kind).toBe("blocked"); + // Sight: the far mouth no longer carries warp sight. + expect(gameLos(state, w.from.cell, w.to.cell)).toBe(false); + // One tunnel: the FAR mouth is walled too, and unwalling it reopens both. + const farStep = stepTarget(boardView(state), w.to.cell, w.to.side); + expect(farStep.kind).toBe("blocked"); + const caster2 = activePlayer(state); + caster2.position = { ...w.to.cell }; + caster2.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" }; + const r2 = applyCommand(state, caster2.id, { + type: "cast", instanceId: "dw#1", + target: { kind: "edge", cell: w.to.cell, side: w.to.side }, + }); + if (!r2.ok) throw new Error(r2.error); + expect(stepTarget(boardView(r2.state), w.from.cell, w.from.side).kind).toBe("warp"); + }); +}); diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 45a0e26..851059d 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -150,6 +150,19 @@ boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 }); } } + // Warp mouths sit on the perimeter, with no far cell: still wallable. + const seen = new Set(boxes.map((b) => `${b.cell.x},${b.cell.y},${b.side}`)); + for (const w of view.board.warps) { + const c = w.from.cell; + const side = w.from.side; + const k = `${c.x},${c.y},${side}`; + if (seen.has(k)) continue; + seen.add(k); + if (side === "E") boxes.push({ cell: c, side, x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 }); + if (side === "W") boxes.push({ cell: c, side, x: c.x * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 }); + if (side === "S") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 }); + if (side === "N") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: c.y * CELL - 6, w: CELL - 8, h: 12 }); + } return boxes; });