From 6ec9d6ab555fb649b7cc366267d9dce60e5e0171 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 19 Aug 2026 21:51:22 -0400 Subject: [PATCH] FEAR's rout: bots flee it, humans are told their duty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Players or monsters must move away from you on their turn if within range, if they can.' Automatons caught in the dread now spend their legs escaping before any other marching — the step that most increases distance, a true dead end excusing them per the card. Humans keep their freedom but get the compulsion spelled out in an urgent slip (move away, even by card or through danger; only a dead end excuses you), with the maze trusting the table to honor it — the owner's chosen depth of enforcement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/automaton.ts | 22 ++++++++++++++++++++ packages/engine/test/automaton.test.ts | 28 ++++++++++++++++++++++++++ packages/web/src/App.svelte | 10 +++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 0953b23..7097216 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1298,6 +1298,28 @@ export function automatonCommand( } } + // FEAR's compulsion: "must move away from you on their turn if within + // range, if they can." Caught in a dread bubble, the clockwork spends + // its legs fleeing before any other marching. + if (view.turn.movementUsed < view.turn.movementAllowance) { + const dreadful = livingEnemies(view).filter((p) => + view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && + Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3); + for (const source of dreadful) { + const here = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y); + let best: { dir: Side; d: number } | null = null; + for (const dir of SIDES) { + const t = stepTarget(view.board, self.position, dir); + if (t.kind === "blocked") continue; + if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue; + const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y); + if (d > here && (best === null || d > best.d)) best = { dir, d }; + } + if (best) return { type: "move", direction: best.dir }; + // Dead end: the card excuses what cannot be done; march on normally. + } + } + // March. The thief-chase outranks everything; the berserker hunts wizards // over gold; the worrier keeps its distance; the hunter goes to the gold. if (view.turn.movementUsed < view.turn.movementAllowance) { diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 60c6b91..65a51e9 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -520,3 +520,31 @@ describe("no number is wasted on a turn that ends at a grab", () => { expect(cmd?.type).not.toBe("playNumberForMovement"); }); }); + +describe("the clockwork flees the dread", () => { + it("caught in FEAR's bubble, it spends its legs moving away", () => { + let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"], deckRev: 32 }); + for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const bot = state.players.find((p) => p.id === "bot")!; + const grim = state.players.find((p) => p.id === "grim")!; + grim.position = { x: 2, y: 5 }; + bot.position = { x: 2, y: 7 }; // two spaces inside the dread + state.sustained.push({ + id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim", + remainingTurns: 5, data: {}, + } as never); + const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage"); + expect(cmd?.type).toBe("move"); + if (cmd?.type === "move") { + const r = applyCommand(state, "bot", cmd); + if (!r.ok) throw new Error(r.error); + const after = r.state.players.find((p) => p.id === "bot")!; + const d = Math.abs(after.position.x - grim.position.x) + Math.abs(after.position.y - grim.position.y); + expect(d).toBeGreaterThan(2); + } + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 30906fe..465ecd5 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1767,6 +1767,16 @@ {/if} + {#if isYourTurn && view.players.some((p) => p.alive && p.id !== view.you && + view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && me && + Math.abs(p.position.x - me.position.x) + Math.abs(p.position.y - me.position.y) <= 3)} +
+ 😱 An unnatural dread grips you — FEAR requires you to move away + this turn if you can, even if your only escape is a card + (Teleport, Pass Through Wall) or a dangerous square. Only a true + dead end excuses you. (The maze trusts you to honor it.) +
+ {/if} {#if actionsSpent} {#if view.turn.drawForbidden}
Your own bolt has left you reeling — the rest of this turn is lost, and no cards come with it.