Three rulings from the night games (rules rev 2)
Teleport wraps the maze's rim: its four counted squares now step through warp mouths like any doorway, so a wizard can blink off one side of the board and onto the other. A wizard at a door's threshold may pull it open and peek — lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in hand — without stepping through. The door still hangs shut to everyone down the hallway; only a HELD door is propped open for every eye. Gated behind rules rev 2 (now sourced from the engine's CURRENT_RULES_REV) so stored games replay with their doors dark; the client's sight mirror learns the same peek. Fear no longer nails a cornered wizard to the floor: a step closer to the dread is allowed when it walks the shortest way out of it, so escape can round a corner. Standing outside and stepping in is still refused. All 15 production ledgers verified before deploy; 269 engine tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
2932d1e500
commit
154e00141c
+116
-38
@@ -225,6 +225,9 @@ export interface CastParams {
|
||||
hold?: boolean;
|
||||
}
|
||||
|
||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||
export const CURRENT_RULES_REV = 2;
|
||||
|
||||
export interface GameConfig {
|
||||
playerIds: PlayerId[];
|
||||
seed: number;
|
||||
@@ -235,17 +238,10 @@ export interface GameConfig {
|
||||
colors?: number[];
|
||||
/**
|
||||
* Rules revision, frozen per game so stored games replay unchanged.
|
||||
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
|
||||
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
|
||||
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
|
||||
* creature blows open a counteraction window like any attack. Rev 5:
|
||||
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
|
||||
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
|
||||
* teleport escape ("does not work against escape" — the card face).
|
||||
* Rev 7: no behavioral change — every board opening carries warp sight
|
||||
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
|
||||
* warp." — the card face). Rev 8: nothing can be created on a warp token,
|
||||
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
|
||||
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
||||
* Rev 2: a wizard beside a door they can open (lock removed, door
|
||||
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
|
||||
* the doorway; the hallway behind them still cannot.
|
||||
*/
|
||||
deckRev?: number;
|
||||
}
|
||||
@@ -348,19 +344,42 @@ export function losBlockers(state: GameState): Record<string, true> {
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
|
||||
* considered to block L.O.S." speaks of a CLOSED door, and
|
||||
* the table holds doors open precisely to cast back through them. */
|
||||
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||
/** A door HELD open is propped ajar for every eye in the hallway. Any
|
||||
* other door hangs shut, and a shut door blocks L.O.S. — see doorsAjar
|
||||
* for the one wizard who may pull it open and peek. */
|
||||
function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||
if (state.heldDoors.length === 0) return board;
|
||||
const edges = { ...board.edges };
|
||||
for (const h of state.heldDoors) delete edges[h.key];
|
||||
return { ...board, edges };
|
||||
}
|
||||
|
||||
/** A wizard at a door's threshold may pull it open and look through
|
||||
* without stepping through: any wizard beside a door whose lock is
|
||||
* REMOVED or that stands unlocked this turn, and a holder of PICK LOCK
|
||||
* or MASTER KEY beside any workable lock. The door still hangs shut to
|
||||
* everyone down the hallway. (Rules rev 2; earlier games replay with
|
||||
* doors dark.) */
|
||||
function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard {
|
||||
if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board;
|
||||
const viewer = state.players.find((p) => p.id === viewerId);
|
||||
if (!viewer || !viewer.alive) return board;
|
||||
const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
|
||||
let edges: Record<string, EdgeState> | null = null;
|
||||
for (const side of SIDES) {
|
||||
const key = edgeKey(viewer.position, side);
|
||||
if (board.edges[key] !== "door") continue;
|
||||
const workable = carriesKey && state.doorStates[key] !== "jammed";
|
||||
if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue;
|
||||
if (!edges) edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
}
|
||||
return edges ? { ...board, edges } : board;
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers. */
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state));
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
|
||||
return sightBetween(doorsAjar(state, viewerId, openedDoors(state, boardView(state))), from, to, losBlockers(state));
|
||||
}
|
||||
|
||||
/** Parse an edge key back into its north/west cell and side. */
|
||||
@@ -409,7 +428,7 @@ function casterLos(
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
): boolean {
|
||||
const board = openHeldDoors(state, perceivedBoard(state, caster.id));
|
||||
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
|
||||
const blockers = losBlockers(state);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
@@ -1306,7 +1325,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
|
||||
// Effectively permanent: broken by the caster attacking the target.
|
||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||
return null;
|
||||
@@ -1474,14 +1493,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const key = cellKey(cmd.target.cell);
|
||||
const creature = creatureAt(state, cmd.target.cell);
|
||||
if (creature) {
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
destroyCreature(state, events, creature, "dispel creation");
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
||||
return null;
|
||||
}
|
||||
const content = state.squareContents[key];
|
||||
if (!content) return "nothing created there";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
delete state.squareContents[key];
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||
return null;
|
||||
@@ -1498,7 +1517,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (target.id === caster.id) return "you cannot drag yourself";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
|
||||
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||
const from = target.position;
|
||||
dragToward(state, target, caster.position);
|
||||
@@ -1507,7 +1526,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
}
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
const objects = state.groundObjects[key];
|
||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||
if (objects && objects.length > 0) {
|
||||
@@ -1584,7 +1603,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
resolve: (state, events, caster) => {
|
||||
for (const opp of state.players) {
|
||||
if (!opp.alive || opp.id === caster.id) continue;
|
||||
if (!gameLos(state, caster.position, opp.position)) continue;
|
||||
if (!gameLos(state, caster.position, opp.position, caster.id)) continue;
|
||||
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
|
||||
retreatFromSight(state, events, opp, caster.position);
|
||||
}
|
||||
@@ -1680,7 +1699,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||
if (!creature) return "no such monster";
|
||||
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
|
||||
if (!gameLos(state, caster.position, creature.position)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight";
|
||||
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
||||
if (boost === "movement") creature.movesPerTurn *= 2;
|
||||
else creature.maxDamage *= 2;
|
||||
@@ -1882,7 +1901,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
(state.groundObjects[key] ?? []).length > 0 ||
|
||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||
if (!hasObject) return "there is nothing there to glue down";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
state.gluedCells[key] = true;
|
||||
// "a duration equal to twice the NUMBER card played"
|
||||
const turns = magnitude.duration * 2;
|
||||
@@ -1906,7 +1925,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
(state.groundObjects[key] ?? []).length > 0 ||
|
||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||
if (!hasObject) return "there is nothing there to lock up";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||
return null;
|
||||
@@ -1924,7 +1943,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ka === kb) return "pick two different squares";
|
||||
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
||||
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
||||
if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight";
|
||||
const itemsA = state.groundObjects[ka] ?? [];
|
||||
const itemsB = state.groundObjects[kb] ?? [];
|
||||
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
||||
@@ -1987,7 +2006,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||
delete state.squareContents[key];
|
||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
||||
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
||||
@@ -2536,7 +2555,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!other || !other.alive) return "no such living player";
|
||||
if (other.id === caster.id) return "that is already your home";
|
||||
if (!gameLos(state, caster.position, other.position)) return "no line of sight to them";
|
||||
if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them";
|
||||
const onHome = (home: Cell) =>
|
||||
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
|
||||
if (onHome(caster.home) !== onHome(other.home)) {
|
||||
@@ -2727,7 +2746,7 @@ function emptySquareTarget(
|
||||
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||
return "the warp shimmers there — nothing can form on it";
|
||||
}
|
||||
if (!gameLos(state, caster.position, cell)) return "no line of sight";
|
||||
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
|
||||
return cell;
|
||||
}
|
||||
|
||||
@@ -3370,8 +3389,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
|
||||
const d = seen.get(cellKey(cur))!;
|
||||
if (d >= 8) break; // teleport range is 4; stop early
|
||||
for (const side of SIDES) {
|
||||
const n = neighbor(cur, side);
|
||||
if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue;
|
||||
let n = neighbor(cur, side);
|
||||
if (!board.cells[cellKey(n)]) {
|
||||
// The maze wraps for walkers; it wraps for teleporters too —
|
||||
// a warp mouth is one step, same as any doorway.
|
||||
const w = board.warps.find(
|
||||
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
|
||||
);
|
||||
if (!w) continue;
|
||||
n = w.to.cell;
|
||||
}
|
||||
if (seen.has(cellKey(n))) continue;
|
||||
seen.set(cellKey(n), d + 1);
|
||||
if (cellKey(n) === cellKey(to)) return d + 1;
|
||||
queue.push(n);
|
||||
@@ -3384,6 +3412,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
|
||||
// Setup
|
||||
|
||||
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
|
||||
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
|
||||
const n = config.playerIds.length;
|
||||
let rng = createRng(config.seed);
|
||||
const events: GameEvent[] = [];
|
||||
@@ -3747,11 +3776,60 @@ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to:
|
||||
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
||||
const d = dreadDistance(board, other.position, to);
|
||||
const dBefore = dreadDistance(board, other.position, from);
|
||||
if (d <= 3 && d < dBefore) return true;
|
||||
if (d <= 3 && d < dBefore) {
|
||||
// Approaching from outside the dread is never willing.
|
||||
if (dBefore > 3) return true;
|
||||
// Already caged inside it: the maze's corners may force a step
|
||||
// that closes the crow-flies distance — permitted only when the
|
||||
// step walks the shortest way OUT of the bubble.
|
||||
const eFrom = escapeSteps(state, board, other.position, from);
|
||||
const eTo = escapeSteps(state, board, other.position, to);
|
||||
if (!(eTo < eFrom)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walking steps (walls and shut doors respected, warp mouths crossed,
|
||||
* fire braved) from `start` to the nearest cell beyond a dread bubble
|
||||
* centered on `dreadFrom`. Infinity when no way out exists.
|
||||
*/
|
||||
function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number {
|
||||
if (dreadDistance(board, dreadFrom, start) > 3) return 0;
|
||||
const passable = (cell: Cell, side: Side): Cell | null => {
|
||||
const key = edgeKey(cell, side);
|
||||
const e = board.edges[key] ?? "open";
|
||||
if (e === "wall") return null;
|
||||
if (e === "door" &&
|
||||
!state.openDoorEdges.includes(key) &&
|
||||
!state.heldDoors.some((h) => h.key === key)) return null;
|
||||
let n = neighbor(cell, side);
|
||||
if (!board.cells[cellKey(n)]) {
|
||||
const w = board.warps.find(
|
||||
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
|
||||
);
|
||||
if (!w) return null;
|
||||
n = w.to.cell;
|
||||
}
|
||||
if (state.squareContents[cellKey(n)]?.kind === "stone") return null;
|
||||
return n;
|
||||
};
|
||||
const seen = new Set<string>([cellKey(start)]);
|
||||
const queue: [Cell, number][] = [[start, 0]];
|
||||
while (queue.length > 0) {
|
||||
const [cur, d] = queue.shift()!;
|
||||
for (const side of SIDES) {
|
||||
const n = passable(cur, side);
|
||||
if (!n || seen.has(cellKey(n))) continue;
|
||||
if (dreadDistance(board, dreadFrom, n) > 3) return d + 1;
|
||||
seen.add(cellKey(n));
|
||||
queue.push([n, d + 1]);
|
||||
}
|
||||
}
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
|
||||
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
|
||||
state.turn.movementUsed++;
|
||||
@@ -4316,7 +4394,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
|
||||
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
|
||||
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
|
||||
const events: GameEvent[] = [];
|
||||
const board = openHeldDoors(state, perceivedBoard(state, p.id));
|
||||
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
|
||||
if (!isAdjacentToEdge(p.position, cell, side) &&
|
||||
!losToEdge(board, p.position, cell, side)) {
|
||||
return err("you cannot see that wall from here");
|
||||
@@ -5089,8 +5167,8 @@ function checkAmbushes(
|
||||
sprung = context.pickedUpTreasure === true;
|
||||
} else if (context.movedFrom) {
|
||||
if (ambush.trigger.kind === "los") {
|
||||
const before = gameLos(state, owner.position, context.movedFrom);
|
||||
const now = gameLos(state, owner.position, actor.position);
|
||||
const before = gameLos(state, owner.position, context.movedFrom, owner.id);
|
||||
const now = gameLos(state, owner.position, actor.position, owner.id);
|
||||
sprung = now && !before;
|
||||
} else if (ambush.trigger.kind === "near") {
|
||||
const dist = (c: Cell) =>
|
||||
@@ -5102,7 +5180,7 @@ function checkAmbushes(
|
||||
|
||||
// The committed spell must be legal right now, or the ambush stays armed.
|
||||
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
|
||||
if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue;
|
||||
if (fx.requiresLos && !gameLos(state, owner.position, actor.position, owner.id)) continue;
|
||||
|
||||
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
|
||||
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
@@ -62,6 +62,8 @@ export interface GameView {
|
||||
/** Safes standing open (their combination entered this turn). */
|
||||
openSafes: string[];
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
/** The rules revision this game was dealt under. */
|
||||
deckRev: number;
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Accumulated attack damage per edge (public — cracks show). */
|
||||
wallDamage: Record<string, number>;
|
||||
@@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
groundObjects: Object.fromEntries(
|
||||
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
||||
),
|
||||
deckRev: state.config.deckRev ?? 1,
|
||||
doorStates: { ...state.doorStates },
|
||||
wallDamage: { ...state.wallDamage },
|
||||
openDoorEdges: [...state.openDoorEdges],
|
||||
@@ -202,11 +205,25 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
|
||||
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||
// Held-open doors are open doorways to the eye.
|
||||
// Held-open doors are open doorways to every eye. Beyond them, the
|
||||
// viewer at a door's threshold may pull it open and peek (rules rev 2):
|
||||
// lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in
|
||||
// hand — mirroring the engine's doorsAjar.
|
||||
let board = view.board;
|
||||
if (view.heldDoorEdges.length > 0) {
|
||||
const openKeys = new Set(view.heldDoorEdges);
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (view.deckRev >= 2 && me?.alive) {
|
||||
const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
|
||||
for (const side of SIDES) {
|
||||
const key = edgeKey(me.position, side);
|
||||
if (board.edges[key] !== "door") continue;
|
||||
const workable = carriesKey && view.doorStates[key] !== "jammed";
|
||||
if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key);
|
||||
}
|
||||
}
|
||||
if (openKeys.size > 0) {
|
||||
const edges = { ...board.edges };
|
||||
for (const k of view.heldDoorEdges) delete edges[k];
|
||||
for (const k of openKeys) delete edges[k];
|
||||
board = { ...board, edges };
|
||||
}
|
||||
const blockers: Record<string, true> = {};
|
||||
|
||||
Reference in New Issue
Block a user