diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 34ca4b2..b4da815 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -20,6 +20,7 @@ import { assembleBoard, cellKey, edgeKey, + findWarp, hasLineOfSight, sightBetween, neighbor, @@ -1410,25 +1411,40 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge"; const { cell, side } = cmd.target; const view = boardView(state); - if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { + // A warp mouth at the maze's rim is a corridor too — the wraparound + // connects it to the opposite edge (rev 39; older ledgers refused it + // and replay so). The fire takes BOTH mouths, so the crossing burns + // no matter which rim the wizard steps in from. + const offBoard = !view.cells[cellKey(neighbor(cell, side))]; + const warp = offBoard && (state.config.deckRev ?? 1) >= 39 + ? findWarp(view, cell, side) : undefined; + if (!view.cells[cellKey(cell)] || (offBoard && !warp)) { return "the fire must span a corridor between two spaces"; } const key = edgeKey(cell, side); if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open"; + const farKey = warp ? edgeKey(warp.to.cell, warp.to.side) : null; + if (farKey && (view.edges[farKey] ?? "open") !== "open") { + return "the far mouth of that warp is sealed"; + } if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; - state.edgeOverrides[key] = "firewall"; - state.createdEdges[key] = true; - const fx: SustainedEffect = { - id: `fx-${state.nextEffectId++}`, - cardId: "wall-of-fire", - casterId: caster.id, - targetId: caster.id, - remainingTurns: Math.max(1, magnitude.duration), - data: {}, - edge: key, + const ignite = (k: string, at: { cell: Cell; side: Side }) => { + state.edgeOverrides[k] = "firewall"; + state.createdEdges[k] = true; + const fx: SustainedEffect = { + id: `fx-${state.nextEffectId++}`, + cardId: "wall-of-fire", + casterId: caster.id, + targetId: caster.id, + remainingTurns: Math.max(1, magnitude.duration), + data: {}, + edge: k, + }; + state.sustained.push(fx); + events.push({ type: "firewallCreated", caster: caster.id, edge: at, turns: fx.remainingTurns }); }; - state.sustained.push(fx); - events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns }); + ignite(key, { cell, side }); + if (warp && farKey) ignite(farKey, { cell: warp.to.cell, side: warp.to.side }); return null; }, }, @@ -3856,10 +3872,17 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { const edge = view.edges[key] ?? "open"; const dest = neighbor(p.position, direction); if (!view.cells[cellKey(dest)]) { - if (isBlinded(state, p)) return blindBump(state, events, p, direction); - return err("blocked"); - } - if (edge === "door" && doorIsOpen(state, key)) { + // A fire on a warp mouth (rev 39 casts allow it): the wraparound + // corridor still runs — through flame. + const warp = edge === "firewall" ? findWarp(view, p.position, direction) : undefined; + if (!warp) { + if (isBlinded(state, p)) return blindBump(state, events, p, direction); + return err("blocked"); + } + p.position = { ...warp.to.cell }; + via = "warp"; + crossedFirewall = true; + } else if (edge === "door" && doorIsOpen(state, key)) { p.position = dest; via = "step"; } else if (edge === "firewall") { diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 8b6299e..b25a30d 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -162,6 +162,35 @@ describe("expansion terrain", () => { // Wave side effects vary by geometry; the melt itself is the pinned behavior. }); + it("wall of fire takes a rim warp from rev 39 — both mouths burn the crossing", () => { + for (const deckRev of [38, 39]) { + let { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + }); + 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 }, + }); + expect(r.ok).toBe(deckRev >= 39); + if (!r.ok) continue; + 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("stone to water melts a door from rev 38 — a small entryway in a stone wall", () => { for (const deckRev of [37, 38]) { const { state } = createGame({ diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 165fa95..1b5d9ba 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 38; +const RULES_REV = 39; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";