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:
co-authored by
Claude Fable 5
parent
766ec0a726
commit
a17b5238f0
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface Room {
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!`
|
||||
|
||||
Reference in New Issue
Block a user