From 461c1a5c83adf9a64654c79dfbce749d7ec889c5 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 5 Sep 2026 22:20:18 -0400 Subject: [PATCH] Rev 17: a warp mouth on a pit's rim is a way off it (M4Q7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kestrel stood beside a pit dug against the board's west edge, walls north and south of it, and was told there was nothing to land on — the rim check counted only neighbouring squares, and the warp mouth beyond the pit is not one. From rev 17 a mouth on the rim is an exit like any square, named at a fork like any other; every older game takes the mouth when no square offers, so the refused crossing goes through as it stands. The jump through a mouth carries `via: "warp"`: the board shimmers both mouths instead of streaking across the maze, and the reel cuts rather than gliding. The automaton leaves a pit only by its exits. Three tests walk the rim under both readings. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/automaton.ts | 27 +++++-- packages/engine/src/game.ts | 34 ++++++--- .../engine/test/expansion-terrain.test.ts | 71 +++++++++++++++++++ packages/web/src/App.svelte | 4 +- packages/web/src/fpv/director.ts | 2 +- packages/web/src/fx.ts | 8 ++- 6 files changed, 124 insertions(+), 22 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 150be90..93bc0de 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -592,14 +592,22 @@ function wallBlastTarget( /** Can a wizard entering the pit at `pit` heading `dir` get off its rim * — the maze's own test: some side other than the way back with a cell * there, no wall between, and no stone on it. */ +/** The sides a walker may leave a pit by, having entered it heading + * `dir`: neighbouring squares first; a warp mouth on the rim only when + * no square offers, the reading every revision accepts. */ +function pitExits(view: GameView, pit: Cell, dir: Side): Side[] { + const footing = (d: Side) => { + if (d === opposite(dir)) return null; + const t = stepTarget(view.board, pit, d); + if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null; + return t.kind; + }; + const floor = SIDES.filter((d) => footing(d) === "step"); + return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp"); +} + function pitLandable(view: GameView, pit: Cell, dir: Side): boolean { - return SIDES.some((d) => { - if (d === opposite(dir)) return false; - const beyond = neighbor(pit, d); - return !!view.board.cells[cellKey(beyond)] && - (view.board.edges[edgeKey(pit, d)] ?? "open") === "open" && - view.squareContents[cellKey(beyond)]?.kind !== "stone"; - }); + return pitExits(view, pit, dir).length > 0; } export function pathToward( @@ -632,6 +640,11 @@ export function pathToward( // direction; with nothing to land on, the maze bounces the leaper // back and charges the stride. Goal or waypoint, that is no road. if (hazard === "pit" && !pitLandable(view, to, dir)) continue; + // Out of a pit only by one of its exits. + if (view.squareContents[cellKey(c)]?.kind === "pit") { + const entry = cameBy.get(cellKey(c))?.dir; + if (entry && !pitExits(view, c, entry).includes(dir)) continue; + } seen.add(k); cameBy.set(k, { prev: cellKey(c), dir, viaDoor }); if (goals.has(k)) { found = k; break; } diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index faf4ff8..3f675bd 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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 = 16; +export const CURRENT_RULES_REV = 17; /** 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 @@ -259,6 +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." }, ]; export interface GameConfig { @@ -693,7 +694,7 @@ export type GameEvent = | { type: "slippedInOoze"; player: PlayerId; at: Cell } | { type: "struggledInOoze"; player: PlayerId; stood: boolean } | { type: "steppedOnTacks"; player: PlayerId; at: Cell } - | { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell } + | { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell; via?: "warp" } | { type: "fellInPit"; player: PlayerId; at: Cell } | { type: "climbedFromPit"; player: PlayerId; success: boolean } | { type: "stuckInSlime"; player: PlayerId; at: Cell } @@ -4414,15 +4415,23 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho // named side — or the pit cannot be entered from here at all. const ledge = (state.config.deckRev ?? 1) >= 14; let landing: Side | null = null; + let landingCell: Cell | null = null; + let landingWarp = false; 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 footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => { + const t = stepTarget(view, pitCell, d); + if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null; + return t; }; - const exits = SIDES.filter((d) => d !== opposite(direction) && open(d)); + 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; if (exits.length === 0) { p.position = from; return err("the pit cannot be crossed from here — nothing to land on beside it"); @@ -4436,6 +4445,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho p.position = from; return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`); } + const foot = footing(landing)!; + landingCell = foot.to; + landingWarp = foot.kind === "warp"; } const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in"); if (roll === 1) { @@ -4447,15 +4459,15 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via }); return { ok: true, state, events }; } - const beyond = neighbor(p.position, landing ?? direction); - const beyondOk = landing !== null || ( + const beyond = landingCell ?? neighbor(p.position, direction); + const beyondOk = landingCell !== null || ( view.cells[cellKey(beyond)] && (view.edges[edgeKey(p.position, direction)] ?? "open") === "open" && state.squareContents[cellKey(beyond)]?.kind !== "stone"); if (beyondOk) { const pitCell = p.position; p.position = beyond; - events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond }); + events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond, ...(landingWarp ? { via: "warp" as const } : {}) }); content = state.squareContents[cellKey(p.position)]; } else { // Nowhere to land: teeter back where you started. diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 128cb45..49fef0e 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -537,3 +537,74 @@ describe("the boobytrap keeps its secret", () => { } }); }); + +describe("a pit on the board's rim (M4Q7)", () => { + /** A square on the outer rim with a warp mouth on one side, entered from + * the square opposite the mouth; its other neighbouring squares listed. */ + function rimPitSite(state: GameState) { + const view = boardView(state); + for (const k of Object.keys(view.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + const pit = { x, y }; + if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue; + for (const mouth of SIDES) { + if (stepTarget(view, pit, mouth).kind !== "warp") continue; + const entry = stepTarget(view, pit, opposite(mouth)); + if (entry.kind !== "step" || state.squareContents[cellKey(entry.to)]) continue; + const floor = SIDES.filter((d) => d !== mouth && d !== opposite(mouth) && stepTarget(view, pit, d).kind === "step"); + if (floor.length === 0) continue; + return { pit, mouth, from: entry.to, floor, far: stepTarget(view, pit, mouth) as { kind: "warp"; to: Cell } }; + } + } + throw new Error("no rim pit site on this board"); + } + const stoneUp = (state: GameState, pit: Cell, sides: Side[], by: string) => { + for (const d of sides) state.squareContents[cellKey(neighbor(pit, d))] = { kind: "stone", damage: 0, createdBy: by }; + }; + for (const deckRev of [17, 16]) { + it(`walls either side, the warp mouth is the way off (rev ${deckRev})`, () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + const site = rimPitSite(state); + const me = activePlayer(state); + me.position = { ...site.from }; + state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; + stoneUp(state, site.pit, site.floor, me.id); + const r = applyCommand(state, me.id, { type: "move", direction: site.mouth }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const p = r.state.players.find((p) => p.id === me.id)!; + if (p.inPit) return; + expect(cellKey(p.position)).toBe(cellKey(site.far.to)); + 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", () => { + for (const deckRev of [17, 16]) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + const site = rimPitSite(state); + const me = activePlayer(state); + me.position = { ...site.from }; + state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; + stoneUp(state, site.pit, site.floor.slice(1), me.id); + const bare = applyCommand(state, me.id, { type: "move", direction: site.mouth }); + if (deckRev >= 17) { + expect(bare.ok).toBe(false); + if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/); + const named = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth }); + expect(named.ok).toBe(true); + if (named.ok) { + const p = named.state.players.find((p) => p.id === me.id)!; + expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true); + } + } else { + expect(bare.ok).toBe(true); + if (bare.ok) { + 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); + } + const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth }); + expect(mouth.ok).toBe(false); + } + } + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 8edc4f8..e3efa05 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -941,8 +941,8 @@ if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue; for (const out of SIDES) { if (out === (side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E")) continue; - const land = { x: pit.x + (out === "E" ? 1 : out === "W" ? -1 : 0), y: pit.y + (out === "S" ? 1 : out === "N" ? -1 : 0) }; - if (cellKey(land) === cellKey(cell)) { tryMove(side, undefined, out); return; } + const foot = stepTarget(view.board, pit, out); + if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; } } } // BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it. diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts index 3a40379..fbdddf7 100644 --- a/packages/web/src/fpv/director.ts +++ b/packages/web/src/fpv/director.ts @@ -133,7 +133,7 @@ export function hurledIn(events: GameEvent[], povId: string): boolean { // and straight, eyes ahead — never a cut that reads as a teleport. return events.some((e) => (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || - e.type === "retreatedInHorror" || e.type === "jumpedPit") && e.player === povId); + e.type === "retreatedInHorror" || (e.type === "jumpedPit" && e.via !== "warp")) && e.player === povId); } export interface Glide { diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts index c35ab42..c9f5d8f 100644 --- a/packages/web/src/fx.ts +++ b/packages/web/src/fx.ts @@ -266,7 +266,13 @@ export function fxForEvents( beat++; break; case "jumpedPit": - push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) }); + if (e.via === "warp") { + // Off the rim through a warp mouth: the mouths shimmer, as for a step. + push({ kind: "portal-cell", at: e.from }); + push({ kind: "portal-cell", at: e.to }, 150); + } else { + push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) }); + } beat++; break; case "fellInPit":