From a17b5238f04d37fcdfe130a30a67213733bda260 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 17:25:25 -0400 Subject: [PATCH] Rev 19: breaching the rim opens a new warp across the maze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destroying a wall on the board's outer edge used to leave a one-sided hole into nothing. The table's physics prevail: the outer rim wraps, so the breach goes clean through — the opposite perimeter wall in the same row or column crumbles too (collapse damage and all), and a new warp pair opens between the two edges, shimmering at both mouths. Rev-gated: stored games hold one-sided breaches and replay so. Sector moves recompute the wraparounds and forget improvised openings, as the maze's own reshaping always has. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 51 ++++++++++++++++++++++++++++++ packages/engine/test/board.test.ts | 36 +++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/fx.ts | 5 +++ packages/web/src/local.svelte.ts | 2 +- packages/web/src/net.svelte.ts | 1 + 6 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 5b55e35..557b5a1 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -24,6 +24,7 @@ import { sightBetween, neighbor, stepTarget, + opposite, } from "./board"; import { buildDeck, @@ -592,6 +593,7 @@ export type GameEvent = | { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string } | { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger } | { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } + | { type: "warpOpened"; a: { cell: Cell; side: Side }; b: { cell: Cell; side: Side } } | { type: "wallDamaged"; player: PlayerId; edge: { cell: Cell; side: Side }; amount: number; total: number; needed: number; source: string } | { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorHeld"; player: PlayerId; edge: { cell: Cell; side: Side } } @@ -744,6 +746,32 @@ interface DamagePipeline { kind: "spell" | "physical"; } +/** The opposite perimeter edge in the same row or column — where the + * maze's default wraparound would connect ("only opposite board edges + * connect"). Null when the board's shape offers no counterpart. */ +function oppositePerimeter( + board: AssembledBoard, cell: Cell, side: Side, +): { cell: Cell; side: Side } | null { + const along = side === "E" || side === "W" ? "x" : "y"; + const fixed = along === "x" ? cell.y : cell.x; + let best: Cell | null = null; + for (const key of Object.keys(board.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + if ((along === "x" ? y : x) !== fixed) continue; + const c = { x, y }; + if (best === null || + (side === "E" || side === "S" ? (along === "x" ? x < best.x : y < best.y) + : (along === "x" ? x > best.x : y > best.y))) { + best = c; + } + } + if (!best || cellKey(best) === cellKey(cell)) return null; + const oppSide = opposite(side); + // The counterpart must itself be a rim: nothing beyond it. + if (board.cells[cellKey(neighbor(best, oppSide))]) return null; + return { cell: best, side: oppSide }; +} + /** The far mouth of the warp whose near mouth is this edge, if any. */ function pairedWarpMouth( board: AssembledBoard, cell: Cell, side: Side, @@ -1035,6 +1063,29 @@ const CARD_EFFECTS: Record delete state.createdEdges[pkey]; events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false }); } + // Breaching the maze's outer rim breaks through BOTH sides — the + // default wraparound connects opposite edges, so a new warp opens + // (rules rev 19; sector moves recompute openings and forget it). + const offBoard = !view.cells[cellKey(neighbor(cell, side))]; + if ((state.config.deckRev ?? 1) >= 19 && 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: "wallDestroyed", caster: caster.id, edge: far, wasDoor: false }); + for (const p of state.players) { + if (p.alive && cellKey(p.position) === cellKey(far.cell)) { + applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical"); + } + } + 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 }); + } + } 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 0c5f93e..b543ce6 100644 --- a/packages/engine/test/board.test.ts +++ b/packages/engine/test/board.test.ts @@ -9,6 +9,7 @@ import { type SectorPlacement, } from "../src/board"; import { buildDeck } from "../src/cards"; +import { edgeKey, type Side } from "../src/board"; import { activePlayer, applyCommand, boardView, createGame, gameLos } from "../src/game"; import { createRng } from "../src/rng"; import { setupBoard } from "../src/setups"; @@ -279,3 +280,38 @@ describe("bricking over a warp mouth", () => { expect(stepTarget(boardView(r2.state), w.from.cell, w.from.side).kind).toBe("warp"); }); }); + +describe("breaching the rim (rules rev 19)", () => { + it("destroying a perimeter wall opens both sides as a new warp", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 }); + 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; + } + // Find a rim wall with a wrap counterpart that is NOT already an opening. + const view = boardView(state); + for (const key of Object.keys(view.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + for (const side of ["N", "E", "S", "W"] as Side[]) { + const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + if (view.cells[`${n.x},${n.y}`]) continue; + if ((view.edges[edgeKey({ x, y }, side)] ?? "open") !== "wall") continue; + const caster = activePlayer(state); + caster.position = { x, y }; + caster.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" }; + const warpsBefore = state.board.warps.length; + const r = applyCommand(state, caster.id, { + type: "cast", instanceId: "dw#1", target: { kind: "edge", cell: { x, y }, side }, + }); + if (!r.ok) throw new Error(r.error); + if (r.state.board.warps.length === warpsBefore) continue; // no counterpart in this row + expect(r.state.board.warps.length).toBe(warpsBefore + 2); + const w = r.state.board.warps[warpsBefore]!; + expect(stepTarget(boardView(r.state), w.from.cell, w.from.side).kind).toBe("warp"); + return; + } + } + throw new Error("setup: no breachable rim wall found"); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 1dfb2dc..d8d04c8 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 = 18; +const RULES_REV = 19; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts index e82f73b..b018bda 100644 --- a/packages/web/src/fx.ts +++ b/packages/web/src/fx.ts @@ -208,6 +208,11 @@ export function fxForEvents( push({ kind: "portal-cell", at: e.to }, 150); beat++; break; + case "warpOpened": + push({ kind: "portal", cell: e.a.cell, side: e.a.side }); + push({ kind: "portal", cell: e.b.cell, side: e.b.side }, 200); + beat++; + break; case "wallCreated": case "wallDestroyed": push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side }); diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 3fbef05..f54771c 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 18, + deckRev: 19, }; const { state, events } = createGame(config); for (const e of events) { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 86dd701..61b5e50 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -56,6 +56,7 @@ export function humanize(e: GameEvent): string | null { case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`; case "wallCreated": return `A wall appears!`; case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`; + case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "trapSprung": return e.cardId === "gift-from-below" ? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`