Rev 14: a pit's rim is a ledge, and the walker chooses the way off (GDCN)

The table's reading of CREATE PIT: a thin ledge runs around the rim,
and "jumping over" is edging along it, trying not to fall. So on a 2,
3, or 4 the walker lands on an open square beside the pit — the only
one if there is one, otherwise the one they name (a click on that
square sends `exit` with the move) — and a pit with no way off cannot
be entered from there at all, refused before any die is rolled. The
FAQ's diagonal crossing at an intersection is this rule.

Older games bounced the walker back and charged the stride; they keep
that under their frozen revision, so GDCN's seven bounces replay. The
clockwork's path search treats a pit as a road when any side of its
rim is open and names the exit its path leaves by. Tests pin the fork,
the single way off, the closed pit under both revisions, and the
brain's route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-04 16:18:42 -04:00
co-authored by Claude Fable 5.1
parent 0750fe3f21
commit c6913ea1bc
4 changed files with 161 additions and 21 deletions
+38 -7
View File
@@ -238,7 +238,7 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 13;
export const CURRENT_RULES_REV = 14;
/** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house
@@ -256,6 +256,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 11, note: "a REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse (FAQ) — a point gained per card drawn, half a point per space walked, permanently — and a FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate." },
{ rev: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a number riding the cast fuels it, and numbers or POWER RUN points played under the dash double; older games doubled only the base allowance and consumed the rider without effect." },
{ rev: 13, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." },
{ rev: 14, note: "Crossing a pit on a 2, 3, or 4 means edging around its rim: the walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it — and a pit with no way off cannot be entered. Older games bounced the walker back and charged the stride." },
];
export interface GameConfig {
@@ -774,7 +775,9 @@ export type CastTarget =
| { kind: "cell"; cell: Cell };
export type Command =
| { type: "move"; direction: Side; over?: boolean }
/** `exit` names the side of a pit to leave by when the way straight
* ahead is closed the ledge runs around the whole rim (rev 14). */
| { type: "move"; direction: Side; over?: boolean; exit?: Side }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId }
| { type: "tearTreasure"; targetId: PlayerId }
@@ -4003,7 +4006,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
}
switch (command.type) {
case "move": return doMove(state, command.direction, command.over === true);
case "move": return doMove(state, command.direction, command.over === true, command.exit);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId);
case "tearTreasure": return doTearTreasure(state, command.targetId);
@@ -4154,7 +4157,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
return { ok: true, state, events };
}
function doMove(prev: GameState, direction: Side, over = false): CommandResult {
function doMove(prev: GameState, direction: Side, over = false, exit?: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
@@ -4330,6 +4333,34 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
// fall in (2 damage, movement over); otherwise you sail across to the far
// side (if there is open floor there).
if (content?.kind === "pit" && !misted && !p.inPit) {
// Rev 14: the rim is a ledge that runs the whole way round. Before any
// roll, the walker needs somewhere off it — straight ahead, or the
// named side — or the pit cannot be entered from here at all.
const ledge = (state.config.deckRev ?? 1) >= 14;
let landing: Side | null = null;
if (ledge) {
const pitCell = p.position;
const open = (d: Side) => {
const b = neighbor(pitCell, d);
return !!view.cells[cellKey(b)] &&
(view.edges[edgeKey(pitCell, d)] ?? "open") === "open" &&
state.squareContents[cellKey(b)]?.kind !== "stone";
};
const exits = SIDES.filter((d) => d !== opposite(direction) && open(d));
if (exits.length === 0) {
p.position = from;
return err("the pit cannot be crossed from here — nothing to land on beside it");
}
if (exit !== undefined && !exits.includes(exit)) {
p.position = from;
return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
}
landing = exit ?? (exits.length === 1 ? exits[0]! : null);
if (landing === null) {
p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
}
}
const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
if (roll === 1) {
p.inPit = true;
@@ -4340,11 +4371,11 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
return { ok: true, state, events };
}
const beyond = neighbor(p.position, direction);
const beyondOk =
const beyond = neighbor(p.position, landing ?? direction);
const beyondOk = landing !== null || (
view.cells[cellKey(beyond)] &&
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
state.squareContents[cellKey(beyond)]?.kind !== "stone";
state.squareContents[cellKey(beyond)]?.kind !== "stone");
if (beyondOk) {
const pitCell = p.position;
p.position = beyond;