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:
Eric Wagoner
2026-08-25 14:28:12 -04:00
co-authored by Claude Fable 5
parent 2932d1e500
commit 154e00141c
6 changed files with 241 additions and 45 deletions
+21 -4
View File
@@ -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> = {};