The mouth on a pit's rim may be named in any game

Only a bare step is a matter of revision: from rev 17 a square beside a
mouth is a fork to name; before it the step lands on the square, as the
recorded games did. Naming the mouth was never recorded, so it opens
for every game.

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-05 22:24:18 -04:00
co-authored by Claude Fable 5.1
parent 461c1a5c83
commit 2812b85016
2 changed files with 16 additions and 9 deletions
+9 -7
View File
@@ -259,7 +259,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ 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." }, { 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." },
{ rev: 15, note: "BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do (FAQ: at an intersection the other player decides). Older games shove straight ahead whenever that way is open; rounding a corner was never possible before and is allowed in every game." }, { rev: 15, note: "BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do (FAQ: at an intersection the other player decides). Older games shove straight ahead whenever that way is open; rounding a corner was never possible before and is allowed in every game." },
{ rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." }, { rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." },
{ rev: 17, note: "A warp mouth on a pit's rim is a way off it like any other square, and a fork with one is the walker's to name. Older games take the mouth only when no square offers — before that, a pit against the board's edge with walls either side could not be crossed at all." }, { rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." },
]; ];
export interface GameConfig { export interface GameConfig {
@@ -4427,11 +4427,13 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
const ways = SIDES.filter((d) => d !== opposite(direction)); const ways = SIDES.filter((d) => d !== opposite(direction));
const floor = ways.filter((d) => footing(d)?.kind === "step"); const floor = ways.filter((d) => footing(d)?.kind === "step");
const mouths = ways.filter((d) => footing(d)?.kind === "warp"); const mouths = ways.filter((d) => footing(d)?.kind === "warp");
// Rev 17: a warp mouth on the rim is a way off it like any other. // A warp mouth on the rim is a way off it like any square, and may
// Before it only a neighbouring square counted, so a pit against // always be named. Only what a bare step does is a matter of
// the board's edge with walls either side could not be crossed at // revision: from rev 17 a square beside a mouth is a fork to name;
// all; older games take the mouth only when no square offers. // before it the step took the square, and takes it still, so the
const exits = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? [...floor, ...mouths] : floor; // recorded games replay as they were played.
const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) { if (exits.length === 0) {
p.position = from; p.position = from;
return err("the pit cannot be crossed from here — nothing to land on beside it"); return err("the pit cannot be crossed from here — nothing to land on beside it");
@@ -4440,7 +4442,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
p.position = from; p.position = from;
return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`); return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
} }
landing = exit ?? (exits.length === 1 ? exits[0]! : null); landing = exit ?? (bare.length === 1 ? bare[0]! : null);
if (landing === null) { if (landing === null) {
p.position = from; p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`); return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
@@ -578,7 +578,7 @@ describe("a pit on the board's rim (M4Q7)", () => {
expect(r.events.some((e) => e.type === "jumpedPit" && e.via === "warp")).toBe(true); expect(r.events.some((e) => e.type === "jumpedPit" && e.via === "warp")).toBe(true);
}); });
} }
it("a square beside the mouth: rev 17 asks which, rev 16 takes the square", () => { it("a square beside the mouth: rev 17 asks which, rev 16 steps to the square but may name the mouth", () => {
for (const deckRev of [17, 16]) { for (const deckRev of [17, 16]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const site = rimPitSite(state); const site = rimPitSite(state);
@@ -602,8 +602,13 @@ describe("a pit on the board's rim (M4Q7)", () => {
const p = bare.state.players.find((p) => p.id === me.id)!; const p = bare.state.players.find((p) => p.id === me.id)!;
expect(p.inPit || cellKey(p.position) === cellKey(neighbor(site.pit, site.floor[0]!))).toBe(true); expect(p.inPit || cellKey(p.position) === cellKey(neighbor(site.pit, site.floor[0]!))).toBe(true);
} }
// Named, the mouth is taken in any revision.
const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth }); const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
expect(mouth.ok).toBe(false); expect(mouth.ok).toBe(true);
if (mouth.ok) {
const p = mouth.state.players.find((p) => p.id === me.id)!;
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
}
} }
} }
}); });