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:
co-authored by
Claude Fable 5.1
parent
dc4d2a1221
commit
461c1a5c83
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user