Rev 19: breaching the rim opens a new warp across the maze

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 17:25:25 -04:00
co-authored by Claude Fable 5
parent 766ec0a726
commit a17b5238f0
6 changed files with 95 additions and 2 deletions
+51
View File
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
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)) {