A warp mouth is a wallable line — and the tunnel seals whole

Create Wall refused the board-edge openings: no far cell, so "walls
must be created between two spaces". But bricking over a lettered
opening is a classic play, and the far space is the tunnel itself.
A warp mouth now counts as wallable — with the wall raised at BOTH
mouths, since a tunnel has one passage: no walking in from the far
board, no warp sight through a sealed pair, and tearing down either
end reopens both. The client grows edge hitboxes at the perimeter
mouths so the line is clickable at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 17:22:49 -04:00
co-authored by Claude Fable 5
parent bed7d849eb
commit 766ec0a726
4 changed files with 87 additions and 2 deletions
+31 -1
View File
@@ -744,6 +744,16 @@ interface DamagePipeline {
kind: "spell" | "physical";
}
/** The far mouth of the warp whose near mouth is this edge, if any. */
function pairedWarpMouth(
board: AssembledBoard, cell: Cell, side: Side,
): { cell: Cell; side: Side } | null {
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
return w ? { cell: w.to.cell, side: w.to.side } : null;
}
const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = {
// --- Attacks: damage ------------------------------------------------------
fireball: {
@@ -979,7 +989,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!cmd.target || cmd.target.kind !== "edge") return "create-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 board-edge warp mouth counts as a wallable line: bricking over
// the opening is a classic play, and the far "space" is the tunnel.
const isWarpMouth = view.warps.some(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
if (!view.cells[cellKey(cell)] || (!view.cells[cellKey(neighbor(cell, side))] && !isWarpMouth)) {
return "walls must be created between two spaces on the board";
}
const key = edgeKey(cell, side);
@@ -988,6 +1003,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
state.edgeOverrides[key] = "wall";
state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
// A warp is ONE tunnel: bricking a mouth walls both ends.
const pair = pairedWarpMouth(view, cell, side);
if (pair) {
const pkey = edgeKey(pair.cell, pair.side);
state.edgeOverrides[pkey] = "wall";
state.createdEdges[pkey] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: pair });
}
return null;
},
},
@@ -1005,6 +1028,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
delete state.doorStates[key];
delete state.createdEdges[key];
events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" });
const pair = pairedWarpMouth(view, cell, side);
if (pair && state.createdEdges[edgeKey(pair.cell, pair.side)]) {
const pkey = edgeKey(pair.cell, pair.side);
state.edgeOverrides[pkey] = "open";
delete state.createdEdges[pkey];
events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false });
}
for (const c of [cell, neighbor(cell, side)]) {
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(c)) {