Every wall card learns the rim (rules rev 40)

The full audit Wall of Fire started: WATERWALL crashes on a warp mouth,
washing both rims inward; ILLUSION WALL hangs its scrim on a mouth (the
create-wall bluff); STONE TO WATER melts a brick over a warp at both ends
and breaches a plain rim wall exactly as DESTROY WALL does — far side
washes out, a warp opens, the wave floods the fresh tunnel; the WARP WAND
bores a temporary wraparound that closes when its wall returns; and
CREATE DOOR stops hanging dead doors onto the void. Older ledgers keep
their refusals and danglings, and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-21 12:04:27 -04:00
co-authored by Claude Fable 5
parent f82e4625af
commit e8df6efcde
4 changed files with 248 additions and 9 deletions
+93 -7
View File
@@ -289,7 +289,12 @@ export interface GameState {
/** Remaining charges per wand card instance (set on first use). */ /** Remaining charges per wand card instance (set on first use). */
wandCharges: Record<string, number>; wandCharges: Record<string, number>;
/** WARP WAND: walls opened for this turn only, with their prior state. */ /** 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). */ /** BOOBYTRAP: four face-down tokens, one real (its cell key is secret). */
boobytraps: { casterId: PlayerId; cells: Cell[]; realKey: string }[]; boobytraps: { casterId: PlayerId; cells: Cell[]; realKey: string }[];
/** GLUE: object cells that cannot be picked up from, by cell key. */ /** GLUE: object cells that cannot be picked up from, by cell key. */
@@ -1456,7 +1461,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge"; if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
const view = boardView(state); 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"; return "the wave must span a corridor between two spaces";
} }
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
@@ -1615,7 +1624,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge"; if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge";
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
const view = boardView(state); 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"; return "the illusion must span two spaces on the board";
} }
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
@@ -1788,6 +1802,27 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
state.edgeOverrides[key] = "open"; state.edgeOverrides[key] = "open";
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); 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; return null;
}, },
}, },
@@ -1841,6 +1876,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
const view = boardView(state); 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.edges[key] ?? "open") === "open") {
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) { if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the door must stand between two spaces"; return "the door must stand between two spaces";
@@ -1964,6 +2004,35 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
delete state.doorStates[key]; delete state.doorStates[key];
delete state.createdEdges[key]; delete state.createdEdges[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: { cell, side } }); 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." // "Wall turns into a WATERWALL with a range and damage of 2."
waveFromEdge(state, events, cell, side, 2); waveFromEdge(state, events, cell, side, 2);
checkVictory(state, events); 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`. */ /** 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 { function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
const pushes: { start: Cell; dir: Side }[] = [ // A warp mouth's "far side" is the opposite rim: the collapse washes both
{ start: cell, dir: opposite(side) }, // mouths inward (rev 40; older ledgers' rim waves pushed into the void
{ start: neighbor(cell, side), dir: side }, // 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) { for (const { start, dir } of pushes) {
let probe = start; let probe = start;
for (let dist = 0; dist < range; dist++) { for (let dist = 0; dist < range; dist++) {
@@ -6308,6 +6388,12 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
for (const t of state.tempWarpEdges) { for (const t of state.tempWarpEdges) {
if (t.prior === null) delete state.edgeOverrides[t.key]; if (t.prior === null) delete state.edgeOverrides[t.key];
else state.edgeOverrides[t.key] = t.prior; 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 }); events.push({ type: "wallsWarpedBack", count: state.tempWarpEdges.length });
state.tempWarpEdges = []; state.tempWarpEdges = [];
@@ -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", () => { it("stone to water melts a door from rev 38 — a small entryway in a stone wall", () => {
for (const deckRev of [37, 38]) { for (const deckRev of [37, 38]) {
const { state } = createGame({ const { state } = createGame({
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
@@ -143,6 +143,46 @@ describe("magic wands", () => {
expect(boardView(state).edges[key]).toBe("wall"); 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", () => { it("deja-vu retrieves from the discard, but never a wand", () => {
let { state } = newGame(); let { state } = newGame();
const me = activePlayer(state); const me = activePlayer(state);
+1 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */ /** 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"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";