From 2950380e237c7f39eeacf03b3418518813703867 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 21:39:34 -0400 Subject: [PATCH] The clockwork stops absorbing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An automaton answered DROP OBJECT with an Absorb, then a Blunt — two counters drained against a spell that carries no points. The brain's price table listed only the classic damage spells; anything unknown was guessed at 2 points, which tripped every damage-counter rung. Utility attacks (dropping, dragging, swapping, peeking) now route past those rungs entirely: the bot passes, or spends a full stop when the effect actually threatens something — a shield for the treasure it carries, a worrier's refusal to be dragged. Stone Dead, Butt Head, Heave-Ho, and Illusionary Attack join the price table while the hood is up. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 26 +++++++++++++- packages/engine/test/automaton.test.ts | 49 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 9622d48..29343b5 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -53,8 +53,18 @@ const ATTACKS: Record STONES.has(c.cardId)).length + : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) : 1; if (stack.kind === "spell") { // REVERSE turns the biggest blasts into a meal. diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 73a6fe6..6731cb5 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -116,3 +116,52 @@ describe("automaton vs automaton", () => { } }); }); + +describe("the clockwork does not waste counters on pointless targets", () => { + function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) { + let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 }); + // burn round 1 + for (let i = 0; i < 2; i++) { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + state = r.state; + } + while (actingSeat(state) !== "human") { + state = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }).state; + } + const human = state.players.find((p: { id: string }) => p.id === "human")!; + const bot = state.players.find((p: { id: string }) => p.id === "bot")!; + bot.position = { ...human.position }; + defenderHand.forEach((c, i) => { bot.hand[i] = c; }); + human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId }; + rig?.(state, "bot"); + const r = applyCommand(state, "human", { + type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" }, + ...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}), + }); + if (!r.ok) throw new Error(`setup: ${r.error}`); + return r.state; + } + + it("passes on drop-object rather than absorbing nothing", () => { + const state = underAttack("drop-object", [ + { instanceId: "absorb#T", cardId: "absorb" }, + { instanceId: "blunt#T", cardId: "blunt" }, + { instanceId: "dagger#T", cardId: "dagger" }, + ]); + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "pass" }); + }); + + it("shields a drop-object aimed at its carried treasure", () => { + const state = underAttack("drop-object", [ + { instanceId: "full-shield#T", cardId: "full-shield" }, + { instanceId: "dagger#T", cardId: "dagger" }, + ], (s, defender) => { + const d = s.players.find((p: { id: string }) => p.id === defender)!; + const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!; + t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id; + }); + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" }); + }); +});