diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 5dc5de8..72264d4 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1374,19 +1374,25 @@ export function automatonCommand( const ownGoldCells = new Set(view.treasures .filter((t) => t.owner === view.you && t.position && !t.carriedBy) .map((t) => cellKey(t.position!))); - // A thief already underfoot is not a destination — with the chase - // moot, march on the real goals (deliver, grab) instead of standing - // on them forever. - const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null; + // The chase exists to deliver a blow, so it claims the march only + // while that blow is still live: an attack in hand, this turn's + // attack unspent, casting not yet closed. Those gates hold still as + // the clockwork walks — a goal keyed to the thief's nearness flips + // underfoot and shuttles the walker on and off their square. Caught + // with the strike still live, it stands its ground (a goal underfoot + // ends the march); otherwise the gold has its legs for the turn. + const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden && + !view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null); + const chasing = thief !== null && strikeLive; const objectives = cursedIdiot && ownGoldCells.size > 0 ? ownGoldCells - : thiefAfar - ? new Set([cellKey(thiefAfar.position)]) + : chasing + ? new Set([cellKey(thief.position)]) : style === "berserker" && !self.carriedTreasureId ? (enemyCells.size > 0 ? enemyCells : gold) : gold; const path = - pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ?? + pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? pathToward(view, self.position, enemyCells, { canUnlock }) ?? // No clean road to anything: grit the teeth and wade the hazards, diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 938381b..2448449 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -14,6 +14,7 @@ import { pushSustained } from "./helpers"; /** Whose input does the maze want right now? */ function actingSeat(state: GameState): PlayerId { return ( + state.wardPending?.ownerId ?? state.stack?.waitingOn ?? state.pendingDiscard ?? state.chaosPending?.queue[0] ?? @@ -735,3 +736,42 @@ describe("the clockwork flees the dread", () => { } }); }); + +describe("the thief-chase holds one goal per turn", () => { + it("a clockwork beside its thief never shuttles on and off their square", () => { + let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + // Round 2, thief's turn burned; the bot acts with a full allowance. + while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const thief = state.players.find((p) => p.id === "thief")!; + const bot = state.players.find((p) => p.id === "bot")!; + // The thief carries the bot's gold and stands one step away. + const mine = state.treasures.find((t) => t.owner === "bot")!; + mine.carriedBy = "thief"; + mine.position = null; + thief.position = { x: 2, y: 4 }; + bot.position = { x: 2, y: 5 }; + state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open"; + // A number card and no attacks: the blow the chase serves cannot land. + bot.hand = []; + bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never); + const visited = [cellKey(bot.position)]; + for (let guard = 0; guard < 40; guard++) { + const view = viewFor(state, "bot"); + const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage"); + if (cmd.type === "endTurn") break; + const r = applyCommand(state, "bot", cmd); + if (!r.ok) break; + state = r.state; + const at = cellKey(state.players.find((p) => p.id === "bot")!.position); + if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at); + } + // No step may return to the square just departed: A-B-A is the shuttle. + for (let i = 2; i < visited.length; i++) { + expect(visited[i]).not.toBe(visited[i - 2]); + } + }); +});