From e30f3f0903d688180d5635c7a10f95c70a10b845 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 20 Aug 2026 21:21:00 -0400 Subject: [PATCH] The automaton guards gold only within sight (X2XN stall) The guard-the-gold rung cast SAFE/GLUE at a floor treasure with no line-of-sight check; the engine refused the cast and the fallback burned the bot's whole turn. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 6 ++- packages/engine/test/automaton.test.ts | 55 +++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 7097216..04c4a12 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1034,9 +1034,11 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm const myFloorTreasure = tier.guardGold ? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy) : undefined; - if (myFloorTreasure && enemyNear) { + // Both spells demand line of sight to the gold; a blind cast would be + // refused and cost the whole turn. + if (myFloorTreasure && enemyNear && sightedCellsFor(view).has(cellKey(myFloorTreasure.position!))) { const safe = inHand(view, "safe"); - if (safe) { + if (safe && !view.squareContents[cellKey(myFloorTreasure.position!)]) { return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } }; } const glue = inHand(view, "glue"); diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 65a51e9..8c81e0e 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -6,7 +6,7 @@ import { type PlayerId, } from "../src/game"; import { cellKey, edgeKey } from "../src/board"; -import { viewFor } from "../src/view"; +import { sightedCellsFor, viewFor } from "../src/view"; import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; /** Whose input does the maze want right now? */ @@ -170,6 +170,59 @@ describe("the clockwork does not waste counters on pointless targets", () => { }); }); +describe("the clockwork guards gold only within sight", () => { + // Game X2XN: the bot blind-cast SAFE at a treasure it could not see, the + // engine refused it, and the fallback burned the whole turn. + function goldOnTheFloor() { + let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 }); + 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")!; + const foe = state.players.find((p) => p.id === "foe")!; + foe.position = { ...bot.position }; + bot.hand = [{ instanceId: "safe#T", cardId: "safe" }]; + const gold = state.treasures.find((t) => t.owner === "bot")!; + gold.carriedBy = null; + return { state, bot, gold }; + } + + it("never blind-casts SAFE at a treasure out of sight", () => { + const { state, bot, gold } = goldOnTheFloor(); + const sighted = sightedCellsFor(viewFor(state, "bot")); + const hidden = Object.keys(state.board.cells).find( + (k) => !sighted.has(k) && !state.squareContents[k], + )!; + const [x, y] = hidden.split(",").map(Number); + gold.position = { x: x!, y: y! }; + + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + // Whatever the brain picks, the engine must accept it — a refusal + // forfeits the bot's turn. + const chosen = cmd ?? automatonFallback(viewFor(state, "bot"), "archmage"); + expect(applyCommand(state, "bot", chosen).ok).toBe(true); + }); + + it("still locks up gold it can see", () => { + const { state, bot, gold } = goldOnTheFloor(); + const sighted = sightedCellsFor(viewFor(state, "bot")); + const seen = [...sighted].find( + (k) => k !== cellKey(bot.position) && !state.squareContents[k], + )!; + const [x, y] = seen.split(",").map(Number); + gold.position = { x: x!, y: y! }; + + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ + type: "cast", instanceId: "safe#T", + target: { kind: "cell", cell: gold.position }, + }); + expect(applyCommand(state, "bot", cmd!).ok).toBe(true); + }); +}); + 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 });