Rev 14: a pit's rim is a ledge, and the walker chooses the way off (GDCN)
The table's reading of CREATE PIT: a thin ledge runs around the rim, and "jumping over" is edging along it, trying not to fall. So on a 2, 3, or 4 the walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name (a click on that square sends `exit` with the move) — and a pit with no way off cannot be entered from there at all, refused before any die is rolled. The FAQ's diagonal crossing at an intersection is this rule. Older games bounced the walker back and charged the stride; they keep that under their frozen revision, so GDCN's seven bounces replay. The clockwork's path search treats a pit as a road when any side of its rim is open and names the exit its path leaves by. Tests pin the fork, the single way off, the closed pit under both revisions, and the brain's route. 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
0750fe3f21
commit
c6913ea1bc
@@ -155,6 +155,13 @@ interface PathResult {
|
|||||||
distance: number;
|
distance: number;
|
||||||
/** A locked door stands on the first step of this path. */
|
/** A locked door stands on the first step of this path. */
|
||||||
doorAhead?: { cell: Cell; side: Side };
|
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.
|
/** 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
|
* when the clockwork can unlock them; the first such door is reported so the
|
||||||
* key gets used before the boot.
|
* key gets used before the boot.
|
||||||
*/
|
*/
|
||||||
/** Can a wizard entering the pit at `pit` heading `dir` land beyond it —
|
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
|
||||||
* the same test the maze applies: a cell there, no wall between, no stone. */
|
* — 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 {
|
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
|
||||||
const beyond = neighbor(pit, dir);
|
return SIDES.some((d) => {
|
||||||
return !!view.board.cells[cellKey(beyond)] &&
|
if (d === opposite(dir)) return false;
|
||||||
(view.board.edges[edgeKey(pit, dir)] ?? "open") === "open" &&
|
const beyond = neighbor(pit, d);
|
||||||
view.squareContents[cellKey(beyond)]?.kind !== "stone";
|
return !!view.board.cells[cellKey(beyond)] &&
|
||||||
|
(view.board.edges[edgeKey(pit, d)] ?? "open") === "open" &&
|
||||||
|
view.squareContents[cellKey(beyond)]?.kind !== "stone";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pathToward(
|
export function pathToward(
|
||||||
@@ -642,15 +653,19 @@ export function pathToward(
|
|||||||
}
|
}
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
let cursor = found;
|
let cursor = found;
|
||||||
|
let second: Side | undefined;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const hop = cameBy.get(cursor)!;
|
const hop = cameBy.get(cursor)!;
|
||||||
if (hop.prev === cellKey(from)) {
|
if (hop.prev === cellKey(from)) {
|
||||||
|
const firstIsPit = view.squareContents[cursor]?.kind === "pit";
|
||||||
return {
|
return {
|
||||||
dir: hop.dir,
|
dir: hop.dir,
|
||||||
distance: depth,
|
distance: depth,
|
||||||
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
||||||
|
...(firstIsPit && second ? { exit: second } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
second = hop.dir;
|
||||||
cursor = hop.prev;
|
cursor = hop.prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1728,7 +1743,7 @@ export function automatonCommand(
|
|||||||
return { type: "move", direction: dir };
|
return { type: "move", direction: dir };
|
||||||
}
|
}
|
||||||
const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock });
|
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.
|
// 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 } };
|
if (run) return { type: "cast", instanceId: run.instanceId, params: { points } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { type: "move", direction: path.dir };
|
return stepAlong(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export interface CastParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** 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
|
/** 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
|
* 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: 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: 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: 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 {
|
export interface GameConfig {
|
||||||
@@ -774,7 +775,9 @@ export type CastTarget =
|
|||||||
| { kind: "cell"; cell: Cell };
|
| { kind: "cell"; cell: Cell };
|
||||||
|
|
||||||
export type Command =
|
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: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
||||||
| { type: "punch"; targetId: PlayerId }
|
| { type: "punch"; targetId: PlayerId }
|
||||||
| { type: "tearTreasure"; targetId: PlayerId }
|
| { type: "tearTreasure"; targetId: PlayerId }
|
||||||
@@ -4003,7 +4006,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (command.type) {
|
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 "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
||||||
case "punch": return doPunch(state, command.targetId);
|
case "punch": return doPunch(state, command.targetId);
|
||||||
case "tearTreasure": return doTearTreasure(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 };
|
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);
|
const blocked = requireActionsAvailable(prev);
|
||||||
if (blocked) return err(blocked);
|
if (blocked) return err(blocked);
|
||||||
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
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
|
// fall in (2 damage, movement over); otherwise you sail across to the far
|
||||||
// side (if there is open floor there).
|
// side (if there is open floor there).
|
||||||
if (content?.kind === "pit" && !misted && !p.inPit) {
|
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");
|
const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
|
||||||
if (roll === 1) {
|
if (roll === 1) {
|
||||||
p.inPit = true;
|
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 });
|
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
const beyond = neighbor(p.position, direction);
|
const beyond = neighbor(p.position, landing ?? direction);
|
||||||
const beyondOk =
|
const beyondOk = landing !== null || (
|
||||||
view.cells[cellKey(beyond)] &&
|
view.cells[cellKey(beyond)] &&
|
||||||
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
|
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
|
||||||
state.squareContents[cellKey(beyond)]?.kind !== "stone";
|
state.squareContents[cellKey(beyond)]?.kind !== "stone");
|
||||||
if (beyondOk) {
|
if (beyondOk) {
|
||||||
const pitCell = p.position;
|
const pitCell = p.position;
|
||||||
p.position = beyond;
|
p.position = beyond;
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game";
|
||||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
|
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", () => {
|
describe("expansion terrain", () => {
|
||||||
it("killer ooze burns on entry and can drop you on your face", () => {
|
it("killer ooze burns on entry and can drop you on your face", () => {
|
||||||
// Across seeds we should see both slips and clean crossings.
|
// Across seeds we should see both slips and clean crossings.
|
||||||
@@ -37,14 +71,62 @@ describe("expansion terrain", () => {
|
|||||||
state = must(state, me.id, {
|
state = must(state, me.id, {
|
||||||
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
|
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)!;
|
const p = state.players.find((p) => p.id === me.id)!;
|
||||||
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
||||||
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
||||||
else jumps++;
|
else jumps++;
|
||||||
}
|
}
|
||||||
expect(falls + jumps + teeters).toBe(12);
|
expect(teeters).toBe(0);
|
||||||
expect(falls).toBeGreaterThan(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", () => {
|
it("dust cloud blinds anyone standing inside it", () => {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
/** A move with its regret checks: stepping off your home still laden,
|
/** A move with its regret checks: stepping off your home still laden,
|
||||||
* or stepping onto ground that bites. */
|
* or stepping onto ground that bites. */
|
||||||
function tryMove(direction: Side, over?: boolean) {
|
function tryMove(direction: Side, over?: boolean, exit?: Side) {
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
|
if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
|
||||||
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
|
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
if (bite[ground]) reasons.push(bite[ground]);
|
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<typeof scheduleFx>[0]) {
|
function playFx(events: Parameters<typeof scheduleFx>[0]) {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
@@ -926,6 +926,18 @@
|
|||||||
return;
|
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.
|
// 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)) {
|
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) {
|
||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
|
|||||||
Reference in New Issue
Block a user