diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b4da815..8d5876f 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -289,7 +289,12 @@ export interface GameState { /** Remaining charges per wand card instance (set on first use). */ wandCharges: Record; /** WARP WAND: walls opened for this turn only, with their prior state. */ - tempWarpEdges: { key: string; prior: EdgeState | null }[]; + tempWarpEdges: { + key: string; + prior: EdgeState | null; + /** A rim wall warped open bores a temporary wraparound; unwind it too. */ + unwarp?: { a: Cell; aSide: Side; b: Cell; bSide: Side }; + }[]; /** BOOBYTRAP: four face-down tokens, one real (its cell key is secret). */ boobytraps: { casterId: PlayerId; cells: Cell[]; realKey: string }[]; /** GLUE: object cells that cannot be picked up from, by cell key. */ @@ -1456,7 +1461,11 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "edge") return "waterwall 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 rim warp mouth is a corridor too (rev 40): the collapse washes + // both rims inward (waveFromEdge follows the tunnel). + const offBoard = !view.cells[cellKey(neighbor(cell, side))]; + const warpMouth = offBoard && (state.config.deckRev ?? 1) >= 40 && findWarp(view, cell, side); + if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { return "the wave must span a corridor between two spaces"; } if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; @@ -1615,7 +1624,12 @@ const CARD_EFFECTS: Record if (!cmd.target || cmd.target.kind !== "edge") return "illusion 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 rim warp mouth takes the scrim too (rev 40) — the bluff of + // create-wall's brick-over. The illusion hangs on the mouth it was + // painted on; the far mouth shows nothing (nothing physical exists). + const offBoard = !view.cells[cellKey(neighbor(cell, side))]; + const warpMouth = offBoard && (state.config.deckRev ?? 1) >= 40 && findWarp(view, cell, side); + if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { return "the illusion must span two spaces on the board"; } const key = edgeKey(cell, side); @@ -1788,6 +1802,27 @@ const CARD_EFFECTS: Record state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); state.edgeOverrides[key] = "open"; events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); + // A rim wall warped open bores a temporary wraparound (rev 40): the + // far rim wall vanishes with it and a warp runs until turn's end. + // (Pre-40 rim casts opened a dead edge and replay so.) + const offBoard = !view.cells[cellKey(neighbor(cell, side))]; + if ((state.config.deckRev ?? 1) >= 40 && offBoard && !findWarp(view, cell, side)) { + const far = oppositePerimeter(view, cell, side); + const farKey = far ? edgeKey(far.cell, far.side) : ""; + if (far && (view.edges[farKey] ?? "open") === "wall") { + state.tempWarpEdges.push({ + key: farKey, prior: state.edgeOverrides[farKey] ?? null, + unwarp: { a: cell, aSide: side, b: far.cell, bSide: far.side }, + }); + state.edgeOverrides[farKey] = "open"; + events.push({ type: "wallWarpedOpen", player: caster.id, edge: far }); + state.board.warps.push( + { from: { cell, side }, to: { cell: far.cell, side: far.side } }, + { from: { cell: far.cell, side: far.side }, to: { cell, side } }, + ); + events.push({ type: "warpOpened", a: { cell, side }, b: far }); + } + } return null; }, }, @@ -1841,6 +1876,11 @@ const CARD_EFFECTS: Record const { cell, side } = cmd.target; const key = edgeKey(cell, side); const view = boardView(state); + // No doors through the maze's rim (rev 40): a rim door would open onto + // nothing traversable — the wraparound belongs to breaches, not doors. + if ((state.config.deckRev ?? 1) >= 40 && !view.cells[cellKey(neighbor(cell, side))]) { + return "the door must stand between two spaces"; + } if ((view.edges[key] ?? "open") === "open") { if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { return "the door must stand between two spaces"; @@ -1964,6 +2004,35 @@ const CARD_EFFECTS: Record delete state.doorStates[key]; delete state.createdEdges[key]; events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: { cell, side } }); + if ((state.config.deckRev ?? 1) >= 40) { + // A brick over a warp mouth melts at BOTH ends — the tunnel is one + // wall line (rev 40, mirroring DESTROY WALL's pair rule). + const pair = pairedWarpMouth(view, cell, side); + const pairKey = pair ? edgeKey(pair.cell, pair.side) : ""; + if (pair && state.createdEdges[pairKey]) { + state.edgeOverrides[pairKey] = "open"; + delete state.createdEdges[pairKey]; + events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: pair }); + } + // Melting the maze's outer rim breaches it like DESTROY WALL does: + // the far rim wall washes out too and a new warp opens, so the + // wave below pours through the fresh tunnel onto both rims. + const offBoard = !view.cells[cellKey(neighbor(cell, side))]; + if (offBoard && !pair) { + const far = oppositePerimeter(view, cell, side); + const farKey = far ? edgeKey(far.cell, far.side) : ""; + if (far && (boardView(state).edges[farKey] ?? "open") === "wall") { + state.edgeOverrides[farKey] = "open"; + delete state.createdEdges[farKey]; + events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: far }); + state.board.warps.push( + { from: { cell, side }, to: { cell: far.cell, side: far.side } }, + { from: { cell: far.cell, side: far.side }, to: { cell, side } }, + ); + events.push({ type: "warpOpened", a: { cell, side }, b: far }); + } + } + } // "Wall turns into a WATERWALL with a range and damage of 2." waveFromEdge(state, events, cell, side, 2); checkVictory(state, events); @@ -2608,10 +2677,21 @@ function waveForce(state: GameState, range: number, dist: number): number { /** A collapsing waterwall wave from an edge: wash players back `range`. */ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void { - const pushes: { start: Cell; dir: Side }[] = [ - { start: cell, dir: opposite(side) }, - { start: neighbor(cell, side), dir: side }, - ]; + // A warp mouth's "far side" is the opposite rim: the collapse washes both + // mouths inward (rev 40; older ledgers' rim waves pushed into the void + // and replay so). A plain edge washes its two adjacent cells apart. + const warp = (state.config.deckRev ?? 1) >= 40 && + !boardView(state).cells[cellKey(neighbor(cell, side))] + ? findWarp(boardView(state), cell, side) : undefined; + const pushes: { start: Cell; dir: Side }[] = warp + ? [ + { start: cell, dir: opposite(side) }, + { start: warp.to.cell, dir: opposite(warp.to.side) }, + ] + : [ + { start: cell, dir: opposite(side) }, + { start: neighbor(cell, side), dir: side }, + ]; for (const { start, dir } of pushes) { let probe = start; for (let dist = 0; dist < range; dist++) { @@ -6308,6 +6388,12 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { for (const t of state.tempWarpEdges) { if (t.prior === null) delete state.edgeOverrides[t.key]; else state.edgeOverrides[t.key] = t.prior; + if (t.unwarp) { + const { a, aSide, b, bSide } = t.unwarp; + state.board.warps = state.board.warps.filter((w) => + !(cellKey(w.from.cell) === cellKey(a) && w.from.side === aSide) && + !(cellKey(w.from.cell) === cellKey(b) && w.from.side === bSide)); + } } events.push({ type: "wallsWarpedBack", count: state.tempWarpEdges.length }); state.tempWarpEdges = []; diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index b25a30d..f76a8a3 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -191,6 +191,119 @@ describe("expansion terrain", () => { } }); + it("waterwall takes a rim warp from rev 40 — the collapse washes both rims", () => { + for (const deckRev of [39, 40]) { + let { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + }); + 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 }, + }); + expect(r.ok).toBe(deckRev >= 40); + if (!r.ok) continue; + // 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 from rev 40", () => { + for (const deckRev of [39, 40]) { + const { 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 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(deckRev >= 40); + if (r.ok) { + expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined(); + } + } + }); + + it("stone to water breaches the outer rim from rev 40, opening a warp", () => { + for (const deckRev of [39, 40]) { + let { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + }); + const me = activePlayer(state); + const board = boardView(state); + // Stand at a plain rim wall (an off-board edge that is no warp mouth). + let rim: { 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) { + const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (board.cells[cellKey(n)]) continue; + if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue; + if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue; + rim = { cell: { x, y }, side }; + break outer; + } + } + if (!rim) throw new Error("setup: no plain rim wall found"); + 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 }, + }); + if (deckRev >= 40) { + // The far rim melted with it and the wraparound now runs. + expect(state.board.warps.length).toBe(warpsBefore + 2); + } else { + // Older vintages left a dangling opening and replay so. + expect(state.board.warps.length).toBe(warpsBefore); + } + } + }); + + it("no doors through the rim from rev 40", () => { + for (const deckRev of [39, 40]) { + const { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev, + }); + const me = activePlayer(state); + const board = boardView(state); + let rim: { 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) { + const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (!board.cells[cellKey(n)] && board.edges[edgeKey({ x, y }, side)] === "wall") { + rim = { cell: { x, y }, side }; + break outer; + } + } + } + if (!rim) throw new Error("setup: no rim wall found"); + 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 }, + }); + // Rev 39 hung a dead door there (and replays so); rev 40 refuses. + expect(r.ok).toBe(deckRev < 40); + } + }); + 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/engine/test/wands.test.ts b/packages/engine/test/wands.test.ts index af1563f..b7981af 100644 --- a/packages/engine/test/wands.test.ts +++ b/packages/engine/test/wands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView } from "../src/game"; +import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers"; @@ -143,6 +143,46 @@ describe("magic wands", () => { expect(boardView(state).edges[key]).toBe("wall"); }); + it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => { + let { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 40, + }); + const me = activePlayer(state); + const view = boardView(state); + // A plain rim wall: off-board neighbor, no existing warp mouth. + let rim: { cell: Cell; side: Side } | null = null; + outer: for (const k of Object.keys(view.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + for (const side of SIDES) { + const dest = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (view.cells[cellKey(dest)]) continue; + if (view.edges[edgeKey({ x, y }, side)] !== "wall") continue; + if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue; + rim = { cell: { x, y }, side }; + break outer; + } + } + if (!rim) throw new Error("setup: no plain rim wall found"); + me.position = { ...rim.cell }; + const warpsBefore = state.board.warps.length; + const wand = giveCard(state, me.id, "warp-wand"); + giveCard(state, me.id, "number-2", "N", 1); + state = must(state, me.id, { + type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"], + target: { kind: "edge", cell: rim.cell, side: rim.side }, + }); + expect(state.board.warps.length).toBe(warpsBefore + 2); + // The wraparound runs: step off the rim and land on the opposite edge. + state = must(state, me.id, { type: "move", direction: rim.side }); + const across = state.players.find((p) => p.id === me.id)!.position; + expect(cellKey(across)).not.toBe(cellKey(rim.cell)); + // Turn's end: the walls return and the warp closes with them. + state = must(state, me.id, { type: "endTurn", draw: 0 }); + expect(boardView(state).edges[edgeKey(rim.cell, rim.side)]).toBe("wall"); + expect(state.board.warps.length).toBe(warpsBefore); + }); + it("deja-vu retrieves from the discard, but never a wand", () => { let { state } = newGame(); const me = activePlayer(state); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 1b5d9ba..d804461 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 = 39; +const RULES_REV = 40; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";