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:
co-authored by
Claude Fable 5
parent
bed7d849eb
commit
766ec0a726
@@ -372,6 +372,9 @@ export function hasWarpLineOfSight(
|
||||
// A filled mouth chokes the tunnel unless the viewer/target IS the mouth.
|
||||
if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue;
|
||||
if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue;
|
||||
// A wall built over either mouth seals the tunnel to sight entirely.
|
||||
if (edgeState(board, mouthA, sideA) !== "open") continue;
|
||||
if (edgeState(board, mouthB, w.to.side) !== "open") continue;
|
||||
|
||||
// Rotation taking the far board's inward direction onto sideA.
|
||||
const u = DIR[inward], v = DIR[sideA];
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type SectorPlacement,
|
||||
} from "../src/board";
|
||||
import { buildDeck } from "../src/cards";
|
||||
import { activePlayer, applyCommand, createGame } from "../src/game";
|
||||
import { activePlayer, applyCommand, boardView, createGame, gameLos } from "../src/game";
|
||||
import { createRng } from "../src/rng";
|
||||
import { setupBoard } from "../src/setups";
|
||||
import { giveCard } from "./helpers";
|
||||
@@ -240,3 +240,42 @@ describe("the aisle warp treats its corner as adjacent — sight included", () =
|
||||
expect(offAxis).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bricking over a warp mouth", () => {
|
||||
it("create wall seals a lettered opening: no passage, no sight", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 });
|
||||
// round 1 passes
|
||||
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;
|
||||
}
|
||||
const w = state.board.warps[0]!;
|
||||
const caster = activePlayer(state);
|
||||
caster.position = { ...w.from.cell };
|
||||
caster.hand[0] = { instanceId: "cw#1", cardId: "create-wall" };
|
||||
const r = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: "cw#1",
|
||||
target: { kind: "edge", cell: w.from.cell, side: w.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
// Passage: stepping into the warp is now a wall.
|
||||
const step = stepTarget(boardView(state), w.from.cell, w.from.side);
|
||||
expect(step.kind).toBe("blocked");
|
||||
// Sight: the far mouth no longer carries warp sight.
|
||||
expect(gameLos(state, w.from.cell, w.to.cell)).toBe(false);
|
||||
// One tunnel: the FAR mouth is walled too, and unwalling it reopens both.
|
||||
const farStep = stepTarget(boardView(state), w.to.cell, w.to.side);
|
||||
expect(farStep.kind).toBe("blocked");
|
||||
const caster2 = activePlayer(state);
|
||||
caster2.position = { ...w.to.cell };
|
||||
caster2.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" };
|
||||
const r2 = applyCommand(state, caster2.id, {
|
||||
type: "cast", instanceId: "dw#1",
|
||||
target: { kind: "edge", cell: w.to.cell, side: w.to.side },
|
||||
});
|
||||
if (!r2.ok) throw new Error(r2.error);
|
||||
expect(stepTarget(boardView(r2.state), w.from.cell, w.from.side).kind).toBe("warp");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,19 @@
|
||||
boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
|
||||
}
|
||||
}
|
||||
// Warp mouths sit on the perimeter, with no far cell: still wallable.
|
||||
const seen = new Set(boxes.map((b) => `${b.cell.x},${b.cell.y},${b.side}`));
|
||||
for (const w of view.board.warps) {
|
||||
const c = w.from.cell;
|
||||
const side = w.from.side;
|
||||
const k = `${c.x},${c.y},${side}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
if (side === "E") boxes.push({ cell: c, side, x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
|
||||
if (side === "W") boxes.push({ cell: c, side, x: c.x * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
|
||||
if (side === "S") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
|
||||
if (side === "N") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: c.y * CELL - 6, w: CELL - 8, h: 12 });
|
||||
}
|
||||
return boxes;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user