Wall of Fire takes the rim warps (rules rev 39)
A warp mouth at the maze's edge is a corridor — the wraparound connects it to the opposite rim — so the fire may bar it. It takes BOTH mouths (each with its own burn-out clock), and the crossing steps through flame for the usual 4 from either side. Older ledgers refused the cast and replay so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2afc4e3973
commit
f82e4625af
@@ -20,6 +20,7 @@ import {
|
|||||||
assembleBoard,
|
assembleBoard,
|
||||||
cellKey,
|
cellKey,
|
||||||
edgeKey,
|
edgeKey,
|
||||||
|
findWarp,
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
sightBetween,
|
sightBetween,
|
||||||
neighbor,
|
neighbor,
|
||||||
@@ -1410,14 +1411,26 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
|
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire 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 warp mouth at the maze's rim is a corridor too — the wraparound
|
||||||
|
// connects it to the opposite edge (rev 39; older ledgers refused it
|
||||||
|
// and replay so). The fire takes BOTH mouths, so the crossing burns
|
||||||
|
// no matter which rim the wizard steps in from.
|
||||||
|
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||||
|
const warp = offBoard && (state.config.deckRev ?? 1) >= 39
|
||||||
|
? findWarp(view, cell, side) : undefined;
|
||||||
|
if (!view.cells[cellKey(cell)] || (offBoard && !warp)) {
|
||||||
return "the fire must span a corridor between two spaces";
|
return "the fire must span a corridor between two spaces";
|
||||||
}
|
}
|
||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open";
|
if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open";
|
||||||
|
const farKey = warp ? edgeKey(warp.to.cell, warp.to.side) : null;
|
||||||
|
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
|
||||||
|
return "the far mouth of that warp is sealed";
|
||||||
|
}
|
||||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||||
state.edgeOverrides[key] = "firewall";
|
const ignite = (k: string, at: { cell: Cell; side: Side }) => {
|
||||||
state.createdEdges[key] = true;
|
state.edgeOverrides[k] = "firewall";
|
||||||
|
state.createdEdges[k] = true;
|
||||||
const fx: SustainedEffect = {
|
const fx: SustainedEffect = {
|
||||||
id: `fx-${state.nextEffectId++}`,
|
id: `fx-${state.nextEffectId++}`,
|
||||||
cardId: "wall-of-fire",
|
cardId: "wall-of-fire",
|
||||||
@@ -1425,10 +1438,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
targetId: caster.id,
|
targetId: caster.id,
|
||||||
remainingTurns: Math.max(1, magnitude.duration),
|
remainingTurns: Math.max(1, magnitude.duration),
|
||||||
data: {},
|
data: {},
|
||||||
edge: key,
|
edge: k,
|
||||||
};
|
};
|
||||||
state.sustained.push(fx);
|
state.sustained.push(fx);
|
||||||
events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns });
|
events.push({ type: "firewallCreated", caster: caster.id, edge: at, turns: fx.remainingTurns });
|
||||||
|
};
|
||||||
|
ignite(key, { cell, side });
|
||||||
|
if (warp && farKey) ignite(farKey, { cell: warp.to.cell, side: warp.to.side });
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3856,10 +3872,17 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
const edge = view.edges[key] ?? "open";
|
const edge = view.edges[key] ?? "open";
|
||||||
const dest = neighbor(p.position, direction);
|
const dest = neighbor(p.position, direction);
|
||||||
if (!view.cells[cellKey(dest)]) {
|
if (!view.cells[cellKey(dest)]) {
|
||||||
|
// A fire on a warp mouth (rev 39 casts allow it): the wraparound
|
||||||
|
// corridor still runs — through flame.
|
||||||
|
const warp = edge === "firewall" ? findWarp(view, p.position, direction) : undefined;
|
||||||
|
if (!warp) {
|
||||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||||
return err("blocked");
|
return err("blocked");
|
||||||
}
|
}
|
||||||
if (edge === "door" && doorIsOpen(state, key)) {
|
p.position = { ...warp.to.cell };
|
||||||
|
via = "warp";
|
||||||
|
crossedFirewall = true;
|
||||||
|
} else if (edge === "door" && doorIsOpen(state, key)) {
|
||||||
p.position = dest;
|
p.position = dest;
|
||||||
via = "step";
|
via = "step";
|
||||||
} else if (edge === "firewall") {
|
} else if (edge === "firewall") {
|
||||||
|
|||||||
@@ -162,6 +162,35 @@ describe("expansion terrain", () => {
|
|||||||
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wall of fire takes a rim warp from rev 39 — both mouths burn the crossing", () => {
|
||||||
|
for (const deckRev of [38, 39]) {
|
||||||
|
let { 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 wof = giveCard(state, me.id, "wall-of-fire", "WF", 0);
|
||||||
|
const r = applyCommand(state, me.id, {
|
||||||
|
type: "cast", instanceId: wof.instanceId,
|
||||||
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(deckRev >= 39);
|
||||||
|
if (!r.ok) continue;
|
||||||
|
state = r.state;
|
||||||
|
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
||||||
|
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
||||||
|
expect(boardView(state).edges[nearKey]).toBe("firewall");
|
||||||
|
expect(boardView(state).edges[farKey]).toBe("firewall");
|
||||||
|
// The corridor still runs — through flame: the crossing lands on the
|
||||||
|
// far rim and burns for 4.
|
||||||
|
state = must(state, me.id, { type: "move", direction: warp.from.side });
|
||||||
|
const after = state.players.find((p) => p.id === me.id)!;
|
||||||
|
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||||
|
expect(after.life).toBe(11);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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({
|
||||||
|
|||||||
@@ -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 = 38;
|
const RULES_REV = 39;
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user