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
+86 -4
View File
@@ -1,10 +1,44 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
/** The sides a walker may leave a pit by, having entered it heading `entry`. */
function rimExits(state: GameState, pit: Cell, entry: Side): Side[] {
const view = boardView(state);
return SIDES.filter((d) => {
if (d === opposite(entry)) return false;
const b = neighbor(pit, d);
return !!view.cells[cellKey(b)] && (view.edges[edgeKey(pit, d)] ?? "open") === "open" &&
state.squareContents[cellKey(b)]?.kind !== "stone";
});
}
/** Two pit sites on a board: a fork (several ways off) and a corridor (one
* way off, straight ahead), each with the square a walker enters from. */
function pitSpots(state: GameState) {
const view = boardView(state);
let fork: { from: Cell; pit: Cell; side: Side; exits: Side[] } | null = null;
let corridor: { from: Cell; pit: Cell; side: Side; beyond: Cell } | null = null;
for (const k of Object.keys(view.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const t = stepTarget(view, { x, y }, side);
if (t.kind !== "step") continue;
if (state.squareContents[k] || state.squareContents[cellKey(t.to)]) continue;
if (view.homes.some((h) => cellKey(h) === k || cellKey(h) === cellKey(t.to))) continue;
const exits = rimExits(state, t.to, side);
if (!fork && exits.length >= 2) fork = { from: { x, y }, pit: t.to, side, exits };
if (!corridor && exits.length === 1 && exits[0] === side) corridor = { from: { x, y }, pit: t.to, side, beyond: neighbor(t.to, side) };
if (fork && corridor) return { fork, corridor };
}
}
throw new Error("board lacks a fork or a corridor pit site");
}
describe("expansion terrain", () => {
it("killer ooze burns on entry and can drop you on your face", () => {
// Across seeds we should see both slips and clean crossings.
@@ -37,14 +71,62 @@ describe("expansion terrain", () => {
state = must(state, me.id, {
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
});
state = must(state, me.id, { type: "move", direction: spot.side });
// Rev 14: the rim's exits are the walker's to name; take the first.
const exits = rimExits(state, spot.cell, spot.side);
if (exits.length === 0) continue;
state = must(state, me.id, { type: "move", direction: spot.side, exit: exits[0] });
const p = state.players.find((p) => p.id === me.id)!;
if (p.inPit) { falls++; expect(p.life).toBe(13); }
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
else jumps++;
}
expect(falls + jumps + teeters).toBe(12);
expect(teeters).toBe(0);
expect(falls).toBeGreaterThan(0);
expect(jumps).toBeGreaterThan(0);
});
it("the rim leads off a pit: one way is taken, several must be named, none cannot be entered", () => {
const found = pitSpots(newGame(42).state);
// Several ways off: a bare step is refused with the choices; a named exit lands there or falls in.
{
let { state } = newGame(42);
const me = activePlayer(state);
me.position = { ...found.fork.from };
state.squareContents[cellKey(found.fork.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
const bare = applyCommand(state, me.id, { type: "move", direction: found.fork.side });
expect(bare.ok).toBe(false);
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
const chosen = found.fork.exits[1]!;
const named = applyCommand(state, me.id, { type: "move", direction: found.fork.side, exit: chosen });
expect(named.ok).toBe(true);
if (named.ok) {
const p = named.state.players.find((p) => p.id === me.id)!;
const landing = neighbor(found.fork.pit, chosen);
expect(p.inPit || cellKey(p.position) === cellKey(landing)).toBe(true);
}
}
// No way off: refused before any die is rolled (rev 14); bounced and charged in an older game.
for (const deckRev of [14, 13]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
me.position = { ...found.corridor.from };
state.squareContents[cellKey(found.corridor.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
state.squareContents[cellKey(found.corridor.beyond)] = { kind: "stone", damage: 0, createdBy: me.id };
const r = applyCommand(state, me.id, { type: "move", direction: found.corridor.side });
if (deckRev >= 14) {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/cannot be crossed/);
} else {
expect(r.ok).toBe(true);
if (r.ok) {
const p = r.state.players.find((p) => p.id === me.id)!;
if (!p.inPit) {
expect(cellKey(p.position)).toBe(cellKey(found.corridor.from));
expect(r.events.some((e) => e.type === "moveBumped")).toBe(true);
}
}
}
}
});
it("dust cloud blinds anyone standing inside it", () => {