From ef5336caa84586f47d8b92e6811fbe9b6f063efe Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 22:11:18 -0400 Subject: [PATCH] =?UTF-8?q?The=20clockwork=20learns=20to=20shed=20a=20dead?= =?UTF-8?q?=20hand=20=E2=80=94=20if=20its=20tier=20knows=20how?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two archmages idled turn after turn: hands clogged with situational neutrals, and doEndTurn caps the draw by the room left, so a full hand never refreshed. Nothing to cast, nothing to draw, nothing done. The brain now sheds dead weight before ending the turn — only cards it has no play for; good counters and attacks stay hoarded — so the draw always has room. The trick is repertoire, so it is tier-gated: adepts and archmages churn, the apprentice hoards like the novice it is. Measured across forty berserker mirrors that sharpened the handicap from two-to-one to three-to-one with zero stalled games. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 31 ++++++++++++++++---- packages/engine/test/automaton.test.ts | 40 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 29343b5..44db2bb 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -31,13 +31,15 @@ interface TierTraits { amplify: boolean; ambush: boolean; guardGold: boolean; + /** Shed dead cards at end of turn to keep the draw flowing. */ + sheds: boolean; buffs: boolean; dejaVu: boolean; } const TIERS: Record = { - apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false }, - adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true }, - archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true }, + apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false, sheds: false }, + adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true, sheds: true }, + archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true, sheds: true }, }; /** Damage attacks the clockwork understands: flat damage plus per-number scaling. */ @@ -234,6 +236,25 @@ function overLimit(view: GameView): number { return Math.max(0, view.yourHand.length - 7); } + +/** End the turn drawing fully — shedding dead weight first if the hand is + * too full to take the draw. doEndTurn caps the draw by the room left, so a + * clogged hand never refreshes; only cards the brain has no play for are + * shed (good counters and attacks are hoarded, as a human would). */ +function endTurnDrawing(view: GameView, tier: TierTraits): Command { + const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7; + const deficit = tier.draw - (limit - view.yourHand.length); + if (tier.sheds && deficit > 0) { + const shed = [...view.yourHand] + .filter((c) => discardValue(c) <= 3) + .sort((a, b) => discardValue(a) - discardValue(b)) + .slice(0, deficit) + .map((c) => c.instanceId); + if (shed.length > 0) return { type: "discard", instanceIds: shed }; + } + return { type: "endTurn", draw: tier.draw }; +} + function worstCards(view: GameView, n: number): string[] { return [...view.yourHand] .sort((a, b) => discardValue(a) - discardValue(b)) @@ -558,7 +579,7 @@ export function automatonCommand( const self = me(view); const here = cellKey(self.position); - if (view.turn.actionsEnded) return { type: "endTurn", draw: tier.draw }; + if (view.turn.actionsEnded) return endTurnDrawing(view, tier); // Deliver or grab treasure underfoot. if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; @@ -681,7 +702,7 @@ export function automatonCommand( } } - return { type: "endTurn", draw: tier.draw }; + return endTurnDrawing(view, tier); } /** The safe fallback when the automaton's choice was refused. */ diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 6731cb5..7d67fd7 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -123,10 +123,13 @@ describe("the clockwork does not waste counters on pointless targets", () => { // burn round 1 for (let i = 0; i < 2; i++) { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(`setup: ${r.error}`); state = r.state; } while (actingSeat(state) !== "human") { - state = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }).state; + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(`setup: ${r.error}`); + state = r.state; } const human = state.players.find((p: { id: string }) => p.id === "human")!; const bot = state.players.find((p: { id: string }) => p.id === "bot")!; @@ -165,3 +168,38 @@ describe("the clockwork does not waste counters on pointless targets", () => { expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" }); }); }); + +describe("a clogged hand gets shed, not hoarded", () => { + it("the bot discards dead weight so the end-of-turn draw has room", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 }); + while (actingSeat(state) !== "bot") { + 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")!; + // Seven situational neutrals the brain has no play for: a dead hand. + bot.hand = Array.from({ length: 7 }, (_, i) => ( + { instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" } + )); + // Walk the bot's turn until it wants to end: it must shed before drawing. + for (let guard = 0; guard < 30; guard++) { + const view = viewFor(state, "bot"); + const cmd = automatonCommand(view, "hunter", "archmage")!; + if (cmd.type === "discard") { + expect(cmd.instanceIds.length).toBe(2); + const r = applyCommand(state, "bot", cmd); + if (!r.ok) throw new Error(r.error); + state = r.state; + const next = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")!; + expect(next).toEqual({ type: "endTurn", draw: 2 }); + return; + } + if (cmd.type === "endTurn") throw new Error("ended the turn without shedding a dead hand"); + const r = applyCommand(state, "bot", cmd); + if (!r.ok) throw new Error(`${cmd.type}: ${r.error}`); + state = r.state; + } + throw new Error("never reached the end of the turn"); + }); +});