From bcc35abb05bc74242e68bcd5b6e23f873b25a83c Mon Sep 17 00:00:00 2001 From: liamiak Date: Sat, 1 Aug 2026 07:59:18 -0600 Subject: [PATCH] Let the AI pay for converge and sunburst when X is the colour knob On a card whose colour count lives in a second SVar, X has one job: buy colours. The AI was not announcing it, so those cards were cast for the minimum. ComputerUtilCost.setMaxXValue ends by setting X on the ability, so it leaves X at the maximum. PermanentAi then measured its converge baseline there, which is already the best colour count available - so the first step of its walk always compared worse and X collapsed to 0. TokenAi missed differently: its X block is guarded by "X".equals(tokenAmount), so a card counting colours in Y never reached any X handling at all. Both want the same thing, so name it once as ComputerUtilMana.setXForBestConverge; PermanentAi loses ten lines to it. Effect on every card in the pool whose cost has X and where Card.hasConverge() is true, with five differently coloured lands: Engineered Explosives X=0 -> 5 (0 charge counters -> 5) Chamber Sentry X=0 -> 5 (entered as a 0/0 and died) Skyrider Elf X=0 -> 3 (entered as a 0/0 and died) Sweep the Skies none -> 4 (converge 1 -> 5) Only Engineered Explosives was flagged; the other three are in AI decks today. It is unflagged here, since casting it for X=0 was the only thing wrong with it - DestroyAllAi already matched Wrath of God's decision on every board tried. Prismatic Ending is the fifth and stays flagged. Announcing X takes its converge to 5 and it still declines: its condition reads Count$Converge, which returns the colours actually paid and so is 0 before the spell is cast. Predicting that in the condition path is separate work. Co-Authored-By: Claude Opus 5 --- .../main/java/forge/ai/ComputerUtilMana.java | 19 +++++ .../java/forge/ai/ability/PermanentAi.java | 12 +-- .../main/java/forge/ai/ability/TokenAi.java | 8 ++ .../forge/ai/ability/ConvergeXCastTest.java | 83 +++++++++++++++++++ .../cardsfolder/e/engineered_explosives.txt | 1 - 5 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 forge-gui-desktop/src/test/java/forge/ai/ability/ConvergeXCastTest.java diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index b7a57a912a54..d98ca6c18f91 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -85,6 +85,25 @@ public static int getConvergeCount(final SpellAbility sa, final Player ai) { return 0; } + /** + * Announce X on a converge or sunburst card, where its only job is to buy colors: the least X + * that still reaches the most of them. Returns the number of colors that will be paid. + */ + public static int setXForBestConverge(final SpellAbility sa, final Player ai, final int maxX) { + int bestX = 0; + int bestColors = 0; + for (int i = 0; i <= maxX; i++) { + sa.setXManaCostPaid(i); + int colors = getConvergeCount(sa, ai); + if (colors > bestColors) { + bestColors = colors; + bestX = i; + } + } + sa.setXManaCostPaid(bestX); + return bestColors; + } + // Does not check if mana sources can be used right now, just checks for potential chance. public static boolean hasEnoughManaSourcesToCast(final SpellAbility sa, final Player ai) { if (ai == null || sa == null) diff --git a/forge-ai/src/main/java/forge/ai/ability/PermanentAi.java b/forge-ai/src/main/java/forge/ai/ability/PermanentAi.java index 39293695f4e2..bb39f13c767c 100644 --- a/forge-ai/src/main/java/forge/ai/ability/PermanentAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/PermanentAi.java @@ -79,17 +79,7 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa if (mana.countX() > 0) { final int xPay = ComputerUtilCost.setMaxXValue(sa, ai, false); if (source.hasConverge()) { - int nColors = ComputerUtilMana.getConvergeCount(sa, ai); - for (int i = 1; i <= xPay; i++) { - sa.setXManaCostPaid(i); - int newColors = ComputerUtilMana.getConvergeCount(sa, ai); - if (newColors > nColors) { - nColors = newColors; - } else { - sa.setXManaCostPaid(i - 1); - break; - } - } + ComputerUtilMana.setXForBestConverge(sa, ai, xPay); } else if (xPay <= 0) { return new AiAbilityDecision(0, AiPlayDecision.CantAffordX); } diff --git a/forge-ai/src/main/java/forge/ai/ability/TokenAi.java b/forge-ai/src/main/java/forge/ai/ability/TokenAi.java index bb3aa9d78366..da4dcbfefed5 100644 --- a/forge-ai/src/main/java/forge/ai/ability/TokenAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/TokenAi.java @@ -81,6 +81,14 @@ protected boolean checkPhaseRestrictions(final Player ai, final SpellAbility sa, return pwPlus || sa.getSubAbility() != null; } + // A converge card counts its colors in a second SVar, because X is already the mana cost - + // so X is not the token count and the block below will not announce it, but it is still + // what buys the colors that count (e.g. Sweep the Skies) + if (!tokenHasX && source.hasConverge() && "Count$xPaid".equals(sa.getSVar("X"))) { + ComputerUtilMana.setXForBestConverge(sa, ai, + ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger())); + } + // X-cost spells if (tokenHasX) { int x = AbilityUtils.calculateAmount(sa.getHostCard(), tokenAmount, sa); diff --git a/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergeXCastTest.java b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergeXCastTest.java new file mode 100644 index 000000000000..9680fd03eb71 --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergeXCastTest.java @@ -0,0 +1,83 @@ +package forge.ai.ability; + +import forge.ai.AITest; +import forge.ai.ComputerUtilMana; +import forge.ai.SpellApiToAi; +import forge.game.Game; +import forge.game.card.Card; +import forge.game.card.CounterEnumType; +import forge.game.phase.PhaseType; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.zone.ZoneType; +import org.testng.annotations.Test; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; + +/** + * On a converge or sunburst card X only buys colors, so the AI announces the least X that reaches + * the most of them - whether the count lands on the card as counters or as the size of the effect. + */ +public class ConvergeXCastTest extends AITest { + + @Test + public void sunburstCountsEveryColourItCouldPayFor() { + Game game = newGame(); + Player ai = game.getPlayers().get(1); + fiveColours(ai); + addCardToZone("Engineered Explosives", ai, ZoneType.Hand); + + settleAndPlay(game, ai); + + Card ee = findCardWithName(game, "Engineered Explosives"); + assertNotNull("the AI cast it", ee); + assertEquals("sunburst counted all five colours", 5, + ee.getCounters(CounterEnumType.CHARGE)); + } + + /** + * Sweep the Skies counts its colors in Y, so X is not the token count and is easy to leave + * unannounced - but it is still what buys the colors. Asserted on the announced X rather than + * on a played turn, because TokenAi gates token spells behind a random roll. + */ + @Test + public void convergeSizesAnEffectTheCostDoesNotName() { + Game game = newGame(); + Player ai = game.getPlayers().get(1); + fiveColours(ai); + addCard("Island", ai); + Card sweep = addCardToZone("Sweep the Skies", ai, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, ai); + game.getAction().checkStateEffects(true); + + SpellAbility sa = sweep.getFirstSpellAbility(); + sa.setActivatingPlayer(ai); + SpellApiToAi.Converter.get(sa.getApi()).canPlayWithSubs(ai, sa); + + assertEquals("X paid for the four extra colours", 4, sa.getXManaCostPaid().intValue()); + assertEquals("so all five are spent", 5, ComputerUtilMana.getConvergeCount(sa, ai)); + } + + private Game newGame() { + Game game = initAndCreateGame(); + game.getPlayers().get(0).setTeam(1); + game.getPlayers().get(1).setTeam(0); + return game; + } + + private void fiveColours(Player p) { + addCard("Plains", p); + addCard("Island", p); + addCard("Swamp", p); + addCard("Mountain", p); + addCard("Forest", p); + } + + private void settleAndPlay(Game game, Player ai) { + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, ai); + game.getAction().checkStateEffects(true); + gameLoopUntilNextPhase(game); + } +} diff --git a/forge-gui/res/cardsfolder/e/engineered_explosives.txt b/forge-gui/res/cardsfolder/e/engineered_explosives.txt index eca0e77dc679..7bc497448fed 100644 --- a/forge-gui/res/cardsfolder/e/engineered_explosives.txt +++ b/forge-gui/res/cardsfolder/e/engineered_explosives.txt @@ -6,6 +6,5 @@ A:AB$ DestroyAll | Cost$ 2 Sac<1/CARDNAME> | ValidCards$ Permanent.nonLand+cmcEQ SVar:X:Count$xPaid SVar:Y:Count$CardCounters.CHARGE SVar:NonStackingEffect:True -AI:RemoveDeck:All DeckHints:Ability$Proliferate Oracle:Sunburst (This enters with a charge counter on it for each color of mana spent to cast it.)\n{2}, Sacrifice Engineered Explosives: Destroy each nonland permanent with mana value equal to the number of charge counters on Engineered Explosives.