From 21884fa26e6488671d49204f323248e2a6adf6d8 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 21:03:40 -0400 Subject: [PATCH] The fireball-then-buddy lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the owner's favorite play: the pact only breaks when its caster attacks the target, so a blow landed BEFORE the pact leaves it intact. After spending its attack, a non-berserker bot with BUDDY in hand signs it onto the weakest visible unpacted enemy — the one it likely just burned — barring their revenge. The berserker skips it: it would only break its own pact tomorrow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/automaton.ts | 17 +++++++++++ packages/engine/test/automaton.test.ts | 40 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 08ab9d3..8b13d2d 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1250,6 +1250,23 @@ export function automatonCommand( } } + // BUDDY after the blow: the wounded cannot come looking for payback — + // the pact holds unless the clockwork strikes them again. The berserker + // skips it; it would only break its own pact tomorrow. + if (tier.buffs && view.turn.attackUsed && style !== "berserker") { + const pact = inHand(view, "buddy"); + if (pact) { + const sighted = sightedCellsFor(view); + const mark = livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && + !view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === p.id)) + .sort((a, b) => a.life - b.life)[0]; + if (mark) { + return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: mark.id } }; + } + } + } + // March. The thief-chase outranks everything; the berserker hunts wizards // over gold; the worrier keeps its distance; the hunter goes to the gold. if (view.turn.movementUsed < view.turn.movementAllowance) { diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 9c66885..8ac968f 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -427,3 +427,43 @@ describe("round two of the owner's tactics", () => { if (!r.ok) throw new Error(r.error); }); }); + +describe("the fireball-then-buddy lockout", () => { + it("burns the target, then signs the pact so they cannot hit back", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { + 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 prey = state.players.find((p) => p.id === "other")!; + prey.position = { ...bot.position }; + bot.hand = [ + { instanceId: "fireball#T", cardId: "fireball" }, + { instanceId: "buddy#T", cardId: "buddy" }, + ]; + // First: the blow. + let cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "fireball#T" }); + let r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + state = r.state; + r = applyCommand(state, "other", { type: "pass" }); + if (!r.ok) throw new Error(r.error); + state = r.state; + // Then: the pact. + cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ + type: "cast", instanceId: "buddy#T", + target: { kind: "player", playerId: "other" }, + }); + r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + state = r.state; + // The pact holds: the victim cannot strike their tormentor. + expect(state.sustained.some( + (s) => s.cardId === "buddy" && s.casterId === "bot" && s.targetId === "other", + )).toBe(true); + }); +});