From 2812b85016ea2aacf7f213b2f1ce4b22e9fcf200 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 5 Sep 2026 22:24:18 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 16 +++++++++------- packages/engine/test/expansion-terrain.test.ts | 9 +++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 3f675bd..3aa8727 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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: 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: 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 { @@ -4427,11 +4427,13 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho const ways = SIDES.filter((d) => d !== opposite(direction)); const floor = ways.filter((d) => footing(d)?.kind === "step"); 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. - // Before it only a neighbouring square counted, so a pit against - // the board's edge with walls either side could not be crossed at - // all; older games take the mouth only when no square offers. - const exits = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? [...floor, ...mouths] : floor; + // A warp mouth on the rim is a way off it like any square, and may + // always be named. Only what a bare step does is a matter of + // revision: from rev 17 a square beside a mouth is a fork to name; + // before it the step took the square, and takes it still, so the + // 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) { p.position = from; 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; 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) { p.position = from; return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`); diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 49fef0e..bcb0e27 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -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); }); } - 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]) { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); 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)!; 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 }); - 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); + } } } });