Rev 17: a warp mouth on a pit's rim is a way off it (M4Q7)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-05 22:20:18 -04:00
co-authored by Claude Fable 5.1
parent dc4d2a1221
commit 461c1a5c83
6 changed files with 124 additions and 22 deletions
+20 -7
View File
@@ -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; }
+23 -11
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 = 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.
@@ -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);
}
}
});
});
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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 {
+6
View File
@@ -266,7 +266,13 @@ export function fxForEvents(
beat++;
break;
case "jumpedPit":
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":