diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 1186126..61d08dd 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -155,6 +155,13 @@ interface PathResult { distance: number; /** A locked door stands on the first step of this path. */ doorAhead?: { cell: Cell; side: Side }; + /** The first step lands on a pit: the side of its rim the path leaves by. */ + exit?: Side; +} + +/** The move that takes a path's first step, naming the pit exit when there is one. */ +function stepAlong(path: PathResult): Command { + return { type: "move", direction: path.dir, ...(path.exit ? { exit: path.exit } : {}) }; } /** One walkable step for the clockwork's pathfinding; null = impassable. @@ -582,13 +589,17 @@ function wallBlastTarget( * when the clockwork can unlock them; the first such door is reported so the * key gets used before the boot. */ -/** Can a wizard entering the pit at `pit` heading `dir` land beyond it — - * the same test the maze applies: a cell there, no wall between, no stone. */ +/** 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. */ function pitLandable(view: GameView, pit: Cell, dir: Side): boolean { - const beyond = neighbor(pit, dir); - return !!view.board.cells[cellKey(beyond)] && - (view.board.edges[edgeKey(pit, dir)] ?? "open") === "open" && - view.squareContents[cellKey(beyond)]?.kind !== "stone"; + 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"; + }); } export function pathToward( @@ -642,15 +653,19 @@ export function pathToward( } if (!found) return null; let cursor = found; + let second: Side | undefined; for (;;) { const hop = cameBy.get(cursor)!; if (hop.prev === cellKey(from)) { + const firstIsPit = view.squareContents[cursor]?.kind === "pit"; return { dir: hop.dir, distance: depth, ...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}), + ...(firstIsPit && second ? { exit: second } : {}), }; } + second = hop.dir; cursor = hop.prev; } } @@ -1728,7 +1743,7 @@ export function automatonCommand( return { type: "move", direction: dir }; } const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock }); - if (approach) return { type: "move", direction: approach.dir }; + if (approach) return stepAlong(approach); } } // Or bank one now, when a crossing beats the walk by enough to spend a card. @@ -1839,7 +1854,7 @@ export function automatonCommand( if (run) return { type: "cast", instanceId: run.instanceId, params: { points } }; } } - return { type: "move", direction: path.dir }; + return stepAlong(path); } } } diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 0e08cce..db1e01c 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 = 13; +export const CURRENT_RULES_REV = 14; /** 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 @@ -256,6 +256,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { rev: 11, note: "a REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse (FAQ) — a point gained per card drawn, half a point per space walked, permanently — and a FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate." }, { rev: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a number riding the cast fuels it, and numbers or POWER RUN points played under the dash double; older games doubled only the base allowance and consumed the rider without effect." }, { rev: 13, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." }, + { 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." }, ]; export interface GameConfig { @@ -774,7 +775,9 @@ export type CastTarget = | { kind: "cell"; cell: Cell }; export type Command = - | { type: "move"; direction: Side; over?: boolean } + /** `exit` names the side of a pit to leave by when the way straight + * ahead is closed — the ledge runs around the whole rim (rev 14). */ + | { type: "move"; direction: Side; over?: boolean; exit?: Side } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "punch"; targetId: PlayerId } | { type: "tearTreasure"; targetId: PlayerId } @@ -4003,7 +4006,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman } switch (command.type) { - case "move": return doMove(state, command.direction, command.over === true); + case "move": return doMove(state, command.direction, command.over === true, command.exit); case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "punch": return doPunch(state, command.targetId); case "tearTreasure": return doTearTreasure(state, command.targetId); @@ -4154,7 +4157,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct return { ok: true, state, events }; } -function doMove(prev: GameState, direction: Side, over = false): CommandResult { +function doMove(prev: GameState, direction: Side, over = false, exit?: Side): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); @@ -4330,6 +4333,34 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { // fall in (2 damage, movement over); otherwise you sail across to the far // side (if there is open floor there). if (content?.kind === "pit" && !misted && !p.inPit) { + // Rev 14: the rim is a ledge that runs the whole way round. Before any + // roll, the walker needs somewhere off it — straight ahead, or the + // named side — or the pit cannot be entered from here at all. + const ledge = (state.config.deckRev ?? 1) >= 14; + let landing: Side | null = null; + 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 exits = SIDES.filter((d) => d !== opposite(direction) && open(d)); + if (exits.length === 0) { + p.position = from; + return err("the pit cannot be crossed from here — nothing to land on beside it"); + } + if (exit !== undefined && !exits.includes(exit)) { + 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); + if (landing === null) { + p.position = from; + return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`); + } + } const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in"); if (roll === 1) { p.inPit = true; @@ -4340,11 +4371,11 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via }); return { ok: true, state, events }; } - const beyond = neighbor(p.position, direction); - const beyondOk = + const beyond = neighbor(p.position, landing ?? direction); + const beyondOk = landing !== null || ( view.cells[cellKey(beyond)] && (view.edges[edgeKey(p.position, direction)] ?? "open") === "open" && - state.squareContents[cellKey(beyond)]?.kind !== "stone"; + state.squareContents[cellKey(beyond)]?.kind !== "stone"); if (beyondOk) { const pitCell = p.position; p.position = beyond; diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index be91145..128cb45 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -1,10 +1,44 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game"; -import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; +import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game"; +import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view"; import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers"; +/** The sides a walker may leave a pit by, having entered it heading `entry`. */ +function rimExits(state: GameState, pit: Cell, entry: Side): Side[] { + const view = boardView(state); + return SIDES.filter((d) => { + if (d === opposite(entry)) return false; + const b = neighbor(pit, d); + return !!view.cells[cellKey(b)] && (view.edges[edgeKey(pit, d)] ?? "open") === "open" && + state.squareContents[cellKey(b)]?.kind !== "stone"; + }); +} + +/** Two pit sites on a board: a fork (several ways off) and a corridor (one + * way off, straight ahead), each with the square a walker enters from. */ +function pitSpots(state: GameState) { + const view = boardView(state); + let fork: { from: Cell; pit: Cell; side: Side; exits: Side[] } | null = null; + let corridor: { from: Cell; pit: Cell; side: Side; beyond: Cell } | null = null; + for (const k of Object.keys(view.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + for (const side of SIDES) { + const t = stepTarget(view, { x, y }, side); + if (t.kind !== "step") continue; + if (state.squareContents[k] || state.squareContents[cellKey(t.to)]) continue; + if (view.homes.some((h) => cellKey(h) === k || cellKey(h) === cellKey(t.to))) continue; + const exits = rimExits(state, t.to, side); + if (!fork && exits.length >= 2) fork = { from: { x, y }, pit: t.to, side, exits }; + if (!corridor && exits.length === 1 && exits[0] === side) corridor = { from: { x, y }, pit: t.to, side, beyond: neighbor(t.to, side) }; + if (fork && corridor) return { fork, corridor }; + } + } + throw new Error("board lacks a fork or a corridor pit site"); +} + + describe("expansion terrain", () => { it("killer ooze burns on entry and can drop you on your face", () => { // Across seeds we should see both slips and clean crossings. @@ -37,14 +71,62 @@ describe("expansion terrain", () => { state = must(state, me.id, { type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell }, }); - state = must(state, me.id, { type: "move", direction: spot.side }); + // Rev 14: the rim's exits are the walker's to name; take the first. + const exits = rimExits(state, spot.cell, spot.side); + if (exits.length === 0) continue; + state = must(state, me.id, { type: "move", direction: spot.side, exit: exits[0] }); const p = state.players.find((p) => p.id === me.id)!; if (p.inPit) { falls++; expect(p.life).toBe(13); } else if (cellKey(p.position) === cellKey(me.position)) teeters++; else jumps++; } - expect(falls + jumps + teeters).toBe(12); + expect(teeters).toBe(0); expect(falls).toBeGreaterThan(0); + expect(jumps).toBeGreaterThan(0); + }); + + it("the rim leads off a pit: one way is taken, several must be named, none cannot be entered", () => { + const found = pitSpots(newGame(42).state); + // Several ways off: a bare step is refused with the choices; a named exit lands there or falls in. + { + let { state } = newGame(42); + const me = activePlayer(state); + me.position = { ...found.fork.from }; + state.squareContents[cellKey(found.fork.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; + const bare = applyCommand(state, me.id, { type: "move", direction: found.fork.side }); + expect(bare.ok).toBe(false); + if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/); + const chosen = found.fork.exits[1]!; + const named = applyCommand(state, me.id, { type: "move", direction: found.fork.side, exit: chosen }); + expect(named.ok).toBe(true); + if (named.ok) { + const p = named.state.players.find((p) => p.id === me.id)!; + const landing = neighbor(found.fork.pit, chosen); + expect(p.inPit || cellKey(p.position) === cellKey(landing)).toBe(true); + } + } + // No way off: refused before any die is rolled (rev 14); bounced and charged in an older game. + for (const deckRev of [14, 13]) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + const me = activePlayer(state); + me.position = { ...found.corridor.from }; + state.squareContents[cellKey(found.corridor.pit)] = { kind: "pit", damage: 0, createdBy: me.id }; + state.squareContents[cellKey(found.corridor.beyond)] = { kind: "stone", damage: 0, createdBy: me.id }; + const r = applyCommand(state, me.id, { type: "move", direction: found.corridor.side }); + if (deckRev >= 14) { + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/cannot be crossed/); + } else { + expect(r.ok).toBe(true); + if (r.ok) { + const p = r.state.players.find((p) => p.id === me.id)!; + if (!p.inPit) { + expect(cellKey(p.position)).toBe(cellKey(found.corridor.from)); + expect(r.events.some((e) => e.type === "moveBumped")).toBe(true); + } + } + } + } }); it("dust cloud blinds anyone standing inside it", () => { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 4a9416c..31a50e3 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -141,7 +141,7 @@ /** A move with its regret checks: stepping off your home still laden, * or stepping onto ground that bites. */ - function tryMove(direction: Side, over?: boolean) { + function tryMove(direction: Side, over?: boolean, exit?: Side) { const reasons: string[] = []; if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) { const t = view.treasures.find((t) => t.id === me!.carriedTreasureId); @@ -165,7 +165,7 @@ if (bite[ground]) reasons.push(bite[ground]); } } - withCaution(reasons, () => dispatch({ type: "move", direction, ...(over ? { over: true } : {}) })); + withCaution(reasons, () => dispatch({ type: "move", direction, ...(over ? { over: true } : {}), ...(exit ? { exit } : {}) })); } function playFx(events: Parameters[0]) { if (!view) return; @@ -926,6 +926,18 @@ return; } } + // A pit beside you, and a click on a square beside IT: cross by that + // side — the rim's ledge runs the whole way round. + for (const side of SIDES) { + const pit = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; + 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; } + } + } // BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it. if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) { for (const side of SIDES) {