From ea3b9e5bd667d4840a24ba178ec1332c9982783b Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 13:27:14 -0400 Subject: [PATCH] The clockwork reads absorb's fine print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An automaton answered NO SPELL with an Absorb — which "has no effect on duration-based spells." Afflictions sat outside the price table and drew the phantom two-point default, tripping the point-soaking rungs. An affliction's weight is now its duration, and the two counters that touch only points — ABSORB and REVERSE — stand aside for it, leaving the ones that genuinely bite durations: full shield, remove curse, blunt, reflection, shieldstone numbers. Pinned: under NO SPELL with both in hand, the bot blunts, never absorbs. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 13 +++++++++---- packages/engine/test/automaton.test.ts | 21 ++++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index ca72f87..3230166 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -315,16 +315,19 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma return { type: "pass" }; } + // An affliction's weight is its duration, not points it does not carry. const incoming = stack.attackCard ? attackId === "stone-dead" // NUMBER times the stones in the victim's hand — our hand. ? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length - : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) + : affliction + ? Math.max(2, stack.numberValue ?? 2) + : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) : 1; if (stack.kind === "spell") { - // REVERSE turns the biggest blasts into a meal. + // REVERSE eats points; a pure duration offers it nothing. const reverse = find("reverse"); - if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId }; + if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId }; // ABSORB SPELL steals the good ones for later. const absorbSpell = find("absorb-spell"); if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId }; @@ -342,8 +345,10 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma const half = find("reflection"); if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId }; } + // ABSORB soaks points only ("no effect on duration-based spells"); + // BLUNT and the stones work on durations too. const absorb = find("absorb"); - if (absorb && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId }; + if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId }; const blunt = find("blunt"); if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; // The berserker shares its pain out of spite. diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index d5660fd..86c07d1 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -117,8 +117,7 @@ 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: GameState, defender: string) => void) { +function underAttackShared(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, 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++) { @@ -143,10 +142,11 @@ describe("the clockwork does not waste counters on pointless targets", () => { }); if (!r.ok) throw new Error(`setup: ${r.error}`); return r.state; - } +} +describe("the clockwork does not waste counters on pointless targets", () => { it("passes on drop-object rather than absorbing nothing", () => { - const state = underAttack("drop-object", [ + const state = underAttackShared("drop-object", [ { instanceId: "absorb#T", cardId: "absorb" }, { instanceId: "blunt#T", cardId: "blunt" }, { instanceId: "dagger#T", cardId: "dagger" }, @@ -156,7 +156,7 @@ describe("the clockwork does not waste counters on pointless targets", () => { }); it("shields a drop-object aimed at its carried treasure", () => { - const state = underAttack("drop-object", [ + const state = underAttackShared("drop-object", [ { instanceId: "full-shield#T", cardId: "full-shield" }, { instanceId: "dagger#T", cardId: "dagger" }, ], (s, defender) => { @@ -203,3 +203,14 @@ describe("a clogged hand gets shed, not hoarded", () => { throw new Error("never reached the end of the turn"); }); }); + +describe("the clockwork honors absorb's fine print", () => { + it("answers NO SPELL with blunt, never absorb — durations soak no points", () => { + const state = underAttackShared("no-spell", [ + { instanceId: "absorb#T", cardId: "absorb" }, + { instanceId: "blunt#T", cardId: "blunt" }, + ]); + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "counteract", instanceId: "blunt#T" }); + }); +});