Creatures walk open doorways, and the interruption gets its fanfare
Player movement asked the full question of a door — removed, keyed this turn, or held — while creature movement only saw "door" and refused, so a skeleton balked at a doorway its master had unlocked for good. One doorIsOpen() now answers for both, and an open doorway costs a wraith none of its wall passes. And the interruption window earns the modal it deserved: "The maze holds its breath" — the card shown, the instruction plain, dismissed with "Seize it". No more hunting the rail for a slip while time stands parted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
44c14f5ce1
commit
e7c8ddc0b3
@@ -2578,9 +2578,15 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
|||||||
const from = creature.position;
|
const from = creature.position;
|
||||||
const target = stepTarget(view, creature.position, direction);
|
const target = stepTarget(view, creature.position, direction);
|
||||||
if (target.kind === "blocked") {
|
if (target.kind === "blocked") {
|
||||||
// WRAITH: "can move ... through 1 wall or object per turn."
|
|
||||||
const dest = neighbor(creature.position, direction);
|
const dest = neighbor(creature.position, direction);
|
||||||
if (
|
// An open doorway (lock removed, keyed, or held) is no obstacle —
|
||||||
|
// and costs a wraith nothing.
|
||||||
|
if (target.by === "door" && doorIsOpen(state, edgeKey(creature.position, direction)) &&
|
||||||
|
view.cells[cellKey(dest)] &&
|
||||||
|
state.squareContents[cellKey(dest)]?.kind !== "stone") {
|
||||||
|
creature.position = dest;
|
||||||
|
} else if (
|
||||||
|
// WRAITH: "can move ... through 1 wall or object per turn."
|
||||||
creature.wallPassesPerTurn > creature.wallPassUsed &&
|
creature.wallPassesPerTurn > creature.wallPassUsed &&
|
||||||
view.cells[cellKey(dest)] &&
|
view.cells[cellKey(dest)] &&
|
||||||
state.squareContents[cellKey(dest)]?.kind !== "stone"
|
state.squareContents[cellKey(dest)]?.kind !== "stone"
|
||||||
@@ -2998,6 +3004,14 @@ function unlockDoor(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An open doorway: lock removed for good, keyed or picked this turn, or
|
||||||
|
* held by a standing wizard. */
|
||||||
|
function doorIsOpen(state: GameState, key: string): boolean {
|
||||||
|
return state.doorStates[key] === "removed" ||
|
||||||
|
state.openDoorEdges.includes(key) ||
|
||||||
|
state.heldDoors.some((h) => h.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
/** A held door needs its holder: standing adjacent and alive. Anything that
|
/** A held door needs its holder: standing adjacent and alive. Anything that
|
||||||
* moves or fells them — a step, a shove, a teleport, a killing blow — lets
|
* moves or fells them — a step, a shove, a teleport, a killing blow — lets
|
||||||
* the door swing shut. */
|
* the door swing shut. */
|
||||||
@@ -3486,8 +3500,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
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" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key) ||
|
if (edge === "door" && doorIsOpen(state, key)) {
|
||||||
state.heldDoors.some((h) => h.key === key))) {
|
|
||||||
p.position = dest;
|
p.position = dest;
|
||||||
via = "step";
|
via = "step";
|
||||||
} else if (edge === "firewall") {
|
} else if (edge === "firewall") {
|
||||||
|
|||||||
@@ -521,3 +521,32 @@ describe("elimination sweeps the board either way (rules rev 14)", () => {
|
|||||||
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
|
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("creatures walk open doorways", () => {
|
||||||
|
it("a skeleton passes a door whose lock was removed", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
|
||||||
|
state = toRound2(state);
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const [key, edge] of Object.entries(view.edges)) {
|
||||||
|
if (edge !== "door") continue;
|
||||||
|
const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""];
|
||||||
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||||
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||||
|
state.doorStates[key] = "removed";
|
||||||
|
state.creatures.push({
|
||||||
|
id: "sk1", kind: "skeleton", controllerId: activePlayer(state).id,
|
||||||
|
position: { x, y }, damage: 0, maxDamage: 4, movesPerTurn: 3,
|
||||||
|
movementUsed: 0, attackUsed: false, justCreated: false,
|
||||||
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||||
|
});
|
||||||
|
const r = applyCommand(state, activePlayer(state).id, {
|
||||||
|
type: "moveCreature", creatureId: "sk1", direction: side,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
const sk = r.state.creatures.find((c) => c.id === "sk1")!;
|
||||||
|
expect(cellKey(sk.position)).toBe(cellKey({ x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||||||
const fxWorkshop = new URLSearchParams(location.search).has("fx");
|
const fxWorkshop = new URLSearchParams(location.search).has("fx");
|
||||||
let attackNoticeSeen = $state<string | null>(null);
|
let attackNoticeSeen = $state<string | null>(null);
|
||||||
|
/** The interruption fanfare, dismissed once per window. */
|
||||||
|
let momentSeen = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (!view?.outOfTurnWindow) momentSeen = false;
|
||||||
|
});
|
||||||
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||||
let boardFx = $state<BoardFx[]>([]);
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
let fxCancels: (() => void)[] = [];
|
let fxCancels: (() => void)[] = [];
|
||||||
@@ -1008,6 +1013,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if view && view.outOfTurnWindow?.playerId === view.you && !momentSeen}
|
||||||
|
<div class="scrim attack-scrim" role="alertdialog" aria-label="your interruption">
|
||||||
|
<div class="attack-notice">
|
||||||
|
<div class="attack-headline">The maze holds its breath</div>
|
||||||
|
<div class="attack-card"><Card card={{ instanceId: "moment", cardId: view.outOfTurnWindow.kind }} /></div>
|
||||||
|
<div class="attack-power">
|
||||||
|
Time parts for your {cardDef(view.outOfTurnWindow.kind).name} —
|
||||||
|
pick an attack card and a target, or let the moment pass.
|
||||||
|
</div>
|
||||||
|
<button class="stamp big" onclick={() => (momentSeen = true)}>Seize it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if openingRolls}
|
{#if openingRolls}
|
||||||
<div class="scrim attack-scrim" role="alertdialog" aria-label="the roll for first wizard">
|
<div class="scrim attack-scrim" role="alertdialog" aria-label="the roll for first wizard">
|
||||||
<div class="attack-notice">
|
<div class="attack-notice">
|
||||||
|
|||||||
Reference in New Issue
Block a user