From 78cf177f94b1b44b9b03704eaa8418e01d2758f0 Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 10:06:03 -0600 Subject: [PATCH 01/13] AI: announce X on a converge card so it buys the colours On a converge or sunburst card X has one job: buy colours. Nothing announced it usefully, so Chamber Sentry was never cast at all (at X=0 it is a free 0/0 that dies), Skyrider Elf arrived as a 2/2 instead of a 5/5, and Sweep the Skies made one thopter instead of five. PermanentAi measured its converge baseline at the maximum X that setMaxXValue leaves behind, so the first step of its walk always compared worse and X collapsed to 0. Rather than fix that loop in place it is deleted: setMaxXValue is where every X announcement already goes, so the choice belongs there. It still returns the largest affordable X, so the xPay <= 0 checks at its 66 call sites are unchanged - only the announced value differs, and only for a spell, since converge counts colours spent casting. getConvergeCount now derives from getConvergeColors instead of running its own copy of the payment simulation. Co-Authored-By: Claude Opus 5 --- .../main/java/forge/ai/ComputerUtilCost.java | 6 +++++ .../main/java/forge/ai/ComputerUtilMana.java | 27 ++++++++++++++++++- .../java/forge/ai/ability/PermanentAi.java | 14 +--------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java index 392f42d03b6e..c796c6f6969a 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java @@ -734,6 +734,12 @@ public static int setMaxXValue(SpellAbility sa, Player ai, final boolean effect) int x = ObjectUtils.defaultIfNull(val, 0); sa.setXManaCostPaid(x); + if (sa.isSpell() && sa.getHostCard() != null && sa.getHostCard().hasConverge()) { + // on a converge or sunburst card X only buys colors, so the useful announcement is the + // least X that reaches the most of them rather than all the mana the AI can find. The + // return value still reports what it could afford. + ComputerUtilMana.setXForBestConverge(sa, ai, x); + } return x; } diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index 39480b9a8d95..787d09751a20 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -78,13 +78,38 @@ private static boolean payManaCost(final Cost cost, final SpellAbility sa, final * Return the number of colors used for payment for Converge */ public static int getConvergeCount(final SpellAbility sa, final Player ai) { + return ColorSet.fromMask(getConvergeColors(sa, ai)).countColors(); + } + + /** + * Return the colors that would be used for payment, as a color mask. + */ + public static byte getConvergeColors(final SpellAbility sa, final Player ai) { ManaCostBeingPaid cost = calculateManaCost(sa.getPayCosts(), sa, ai, true, 0, false); if (payManaCost(cost, sa, ai, true, true, false) != null) { - return cost.getSunburst(); + return cost.getColorsPaid(); } 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. + */ + public static void 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); + } + // 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 85451d60d2dd..774cc2f106ff 100644 --- a/forge-ai/src/main/java/forge/ai/ability/PermanentAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/PermanentAi.java @@ -78,19 +78,7 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa ManaCost mana = sa.getPayCosts().getTotalMana(); if (mana.countX() > 0) { final int xPay = ComputerUtilCost.setMaxXValue(sa, ai, false); - if (source.hasConverge()) { - int nColors = -1; - for (int i = 0; i <= xPay; i++) { - sa.setXManaCostPaid(i); - int newColors = ComputerUtilMana.getConvergeCount(sa, ai); - if (newColors > nColors) { - nColors = newColors; - } else { - sa.setXManaCostPaid(i - 1); - break; - } - } - } else if (xPay <= 0) { + if (!source.hasConverge() && xPay <= 0) { return new AiAbilityDecision(0, AiPlayDecision.CantAffordX); } } else if (mana.isZero()) { From 5c11ade3fa2c298a0a69af1493405d21eb427cd5 Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 10:06:03 -0600 Subject: [PATCH 02/13] AI: predict converge when deciding whether to cast Count$Converge reads the colours actually spent, which nothing has while the AI is still deciding, so every converge effect was evaluated as if it were empty. On Bring to Light that means ChangeType$ Creature.cmcLEX with X=0 - it searches for a 0-drop, finds nothing, and never casts. canPlayAndPayFor already lends the card its castSA for this same reason, so lend it the payment it is about to make too, announcing X first because that is what buys the colours being measured. Co-Authored-By: Claude Opus 5 --- .../src/main/java/forge/ai/AiController.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 2625df42e3f1..c22ab8b23621 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -44,6 +44,7 @@ import forge.game.combat.CombatUtil; import forge.game.cost.*; import forge.game.keyword.Keyword; +import forge.game.mana.Mana; import forge.game.mana.ManaCostBeingPaid; import forge.game.phase.PhaseType; import forge.game.player.Player; @@ -814,6 +815,7 @@ public boolean reserveManaSources(SpellAbility sa, PhaseType phaseType, boolean private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { final Card host = sa.getHostCard(); Card altHost = host; + boolean lentConverge = false; if (sa instanceof Spell sp) { altHost = sp.canPlayFromHost(); @@ -821,6 +823,7 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { return AiPlayDecision.CantPlaySa; } altHost.setCastSA(sa); + lentConverge = predictConvergePayment(sa, altHost); } else if (!sa.canPlay()) { return AiPlayDecision.CantPlaySa; } @@ -838,11 +841,36 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { if (sa.isSpell()) { altHost.setCastSA(null); + if (lentConverge) { + sa.getPayingMana().clear(); + } } return decision; } + /** + * Converge counts the colors actually spent, which nothing has yet - so Count$Converge would + * report zero and the AI would size every converge effect as if it were empty. The cast SA is + * already borrowed above for the same reason; lend it the payment it is about to make too. + */ + private boolean predictConvergePayment(final SpellAbility sa, final Card host) { + if (!host.hasConverge() || !sa.getPayingMana().isEmpty()) { + return false; + } + if (sa.costHasManaX()) { + // announce X first - on these cards it is what buys the colors being measured + ComputerUtilCost.setMaxXValue(sa, player, sa.isTrigger()); + } + final byte colors = ComputerUtilMana.getConvergeColors(sa, player); + for (byte color : MagicColor.WUBRG) { + if ((colors & color) != 0) { + sa.getPayingMana().add(new Mana(color, host, null, player)); + } + } + return !sa.getPayingMana().isEmpty(); + } + // This is for playing spells regularly (no Cascade/Ripple etc.) private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) { final Card host = sa.getHostCard(); From 0dfd8e81883be75c13ac5be58f1b2d710e5b6c82 Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 10:06:04 -0600 Subject: [PATCH 03/13] AI: tutoring from your own library should take the best card chooseCardToHiddenOriginChangeZone treats Destination$ Exile as "exiling or bouncing stuff", so when the library being searched is the AI's own it picks getWorstAI. That is right for exile-as-removal, but a tutor exiles as a staging step before a DB$ Play casts the card - Bring to Light deliberately searched out the worst creature it could legally find. Nine cards are Library -> Exile with a Play sub-ability and eight of them are unflagged, so this is live: Beseech the Mirror, Emergent Ultimatum, Evolving Door, Jace Architect of Thought, Kasmina Enigma Sage, Portent of Calamity, The Heron Moon, Djinn of Wishes. Searching an opponent's library still takes their best card - that branch is untouched. Unflags Bring to Light. Co-Authored-By: Claude Opus 5 --- forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java | 4 ++++ forge-gui/res/cardsfolder/b/bring_to_light.txt | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java index 099915a4af67..7eef3948cc4d 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1561,6 +1561,10 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List // Exiling or bouncing stuff if (player.isOpponentOf(decider)) { c = ComputerUtilCard.getBestAI(fetchList); + } else if (origin.contains(ZoneType.Library)) { + // searching your own library is a tutor - exile is where the card waits to be + // played, not somewhere to dump the worst thing you own + c = ComputerUtilCard.getBestAI(fetchList); } else { if (!sa.hasParam("Mandatory") && origin.contains(ZoneType.Battlefield) && sa.hasParam("ChangeNum")) { // exclude tokens, they won't come back, and enchanted stuff, since auras will go away diff --git a/forge-gui/res/cardsfolder/b/bring_to_light.txt b/forge-gui/res/cardsfolder/b/bring_to_light.txt index 82796f7fae6a..51cd768a1749 100644 --- a/forge-gui/res/cardsfolder/b/bring_to_light.txt +++ b/forge-gui/res/cardsfolder/b/bring_to_light.txt @@ -5,5 +5,4 @@ A:SP$ ChangeZone | Origin$ Library | Destination$ Exile | ChangeType$ Creature.c SVar:DBPlay:DB$ Play | Defined$ Remembered | ValidSA$ Spell | WithoutManaCost$ True | Optional$ True | SubAbility$ DBCleanup SVar:DBCleanup:DB$ Cleanup | ClearRemembered$ True SVar:X:Count$Converge -AI:RemoveDeck:All Oracle:Converge — Search your library for a creature, instant, or sorcery card with mana value less than or equal to the number of colors of mana spent to cast this spell, exile that card, then shuffle. You may cast that card without paying its mana cost. From 01aac003593e91f4c917ebad84afa86b49094282 Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 10:06:04 -0600 Subject: [PATCH 04/13] Test for the AI changes above Each of the three fails without its own commit: Chamber Sentry is not cast, Bring to Light is not cast, and Bring to Light takes a Runeclaw Bear over the Serra Angel. Co-Authored-By: Claude Opus 5 --- .../ai/ability/ConvergePredictionTest.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java diff --git a/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java new file mode 100644 index 000000000000..0a7d61480b47 --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java @@ -0,0 +1,81 @@ +package forge.ai.ability; + +import forge.ai.AITest; +import forge.game.Game; +import forge.game.phase.PhaseType; +import forge.game.player.Player; +import forge.game.zone.ZoneType; +import org.testng.annotations.Test; + +import forge.game.card.Card; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; + +/** + * Converge sizes a spell by the colors spent casting it, which are unknown until it is cast. The AI + * predicts the payment so it does not evaluate every converge effect as if it were empty. + */ +public class ConvergePredictionTest extends AITest { + + @Test + public void bringToLightSearchesForWhatItCanActuallyAfford() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + Player opp = game.getPlayers().get(0); + ai.setTeam(0); + opp.setTeam(1); + + addCard("Plains", ai); + addCard("Island", ai); + addCard("Swamp", ai); + addCard("Mountain", ai); + addCard("Forest", ai); + fillLibrary(ai, 20); + fillLibrary(opp, 20); + // mana value 5, so only a legal target once converge counts all five colours - and the + // library is full of mana value 2 Bears it could settle for instead + addCardToZone("Serra Angel", ai, ZoneType.Library); + + addCardToZone("Bring to Light", ai, ZoneType.Hand); + + // past turn 3 the AI picks the best creature rather than the cheapest castable one + for (int i = 0; i < 4; i++) { + ai.incrementTurn(); + } + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, ai, false, 4); + game.getAction().checkStateEffects(true); + playUntilNextTurn(game); + + assertEquals("the AI cast Bring to Light", 1, + countCardsWithName(game, "Bring to Light", ZoneType.Graveyard)); + + assertNotNull("it took the best card it could reach, not the cheapest", + findCardWithName(game, "Serra Angel")); + } + + @Test + public void convergeSizesTheSpellThatBoughtTheColours() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + Player opp = game.getPlayers().get(0); + ai.setTeam(0); + opp.setTeam(1); + + for (String l : new String[]{"Plains", "Island", "Swamp", "Mountain", "Forest"}) { + addCard(l, ai); + } + fillLibrary(ai, 15); + fillLibrary(opp, 15); + // X is the only thing buying colours here, and nothing else announces it + addCardToZone("Chamber Sentry", ai, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, ai); + game.getAction().checkStateEffects(true); + gameLoopUntilNextPhase(game); + + Card sentry = findCardWithName(game, "Chamber Sentry"); + assertNotNull("the AI cast Chamber Sentry", sentry); + assertEquals("sunburst counted all five colours", 5, sentry.getNetToughness()); + } +} From 3571151866056b679097bc48e16a8f7605d69f03 Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 12:02:44 -0600 Subject: [PATCH 05/13] AI: predict the colours rather than fabricating the payment The prediction was stored as Mana objects pushed onto payingMana, which is a multiset with provenance - so it was honest about colour and invented the other two dimensions: one entry per colour, each sourced from the spell itself. Count$Adamant, Count$TotalManaSpent and Count$EachSpentToCast all read those. Store a colour mask instead, ORed in by getPayingColors. That narrows the reach to the four callers with colour-set semantics - Converge, ManaColorsPaid, and the two ManaSpent/ManaNotSpent checks - each of which reads zero today for a spell the AI has not cast yet. Also drop ManaCostBeingPaid.getSunburst, whose only caller was the getConvergeCount body replaced in the previous commit. It was ColorSet.fromMask(sunburstMap).countColors() over the same field getColorsPaid returns, now inlined at the one site that wanted the count. Co-Authored-By: Claude Opus 5 --- .../src/main/java/forge/ai/AiController.java | 22 +++++-------------- .../forge/game/mana/ManaCostBeingPaid.java | 4 ---- .../forge/game/spellability/SpellAbility.java | 12 +++++++++- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index c22ab8b23621..c813468fc8a5 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -44,7 +44,6 @@ import forge.game.combat.CombatUtil; import forge.game.cost.*; import forge.game.keyword.Keyword; -import forge.game.mana.Mana; import forge.game.mana.ManaCostBeingPaid; import forge.game.phase.PhaseType; import forge.game.player.Player; @@ -815,7 +814,6 @@ public boolean reserveManaSources(SpellAbility sa, PhaseType phaseType, boolean private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { final Card host = sa.getHostCard(); Card altHost = host; - boolean lentConverge = false; if (sa instanceof Spell sp) { altHost = sp.canPlayFromHost(); @@ -823,7 +821,7 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { return AiPlayDecision.CantPlaySa; } altHost.setCastSA(sa); - lentConverge = predictConvergePayment(sa, altHost); + predictConvergePayment(sa, altHost); } else if (!sa.canPlay()) { return AiPlayDecision.CantPlaySa; } @@ -841,9 +839,7 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { if (sa.isSpell()) { altHost.setCastSA(null); - if (lentConverge) { - sa.getPayingMana().clear(); - } + sa.setPredictedPayingColors((byte) 0); } return decision; @@ -852,23 +848,17 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { /** * Converge counts the colors actually spent, which nothing has yet - so Count$Converge would * report zero and the AI would size every converge effect as if it were empty. The cast SA is - * already borrowed above for the same reason; lend it the payment it is about to make too. + * already borrowed above for the same reason; tell it which colors it is about to spend too. */ - private boolean predictConvergePayment(final SpellAbility sa, final Card host) { + private void predictConvergePayment(final SpellAbility sa, final Card host) { if (!host.hasConverge() || !sa.getPayingMana().isEmpty()) { - return false; + return; } if (sa.costHasManaX()) { // announce X first - on these cards it is what buys the colors being measured ComputerUtilCost.setMaxXValue(sa, player, sa.isTrigger()); } - final byte colors = ComputerUtilMana.getConvergeColors(sa, player); - for (byte color : MagicColor.WUBRG) { - if ((colors & color) != 0) { - sa.getPayingMana().add(new Mana(color, host, null, player)); - } - } - return !sa.getPayingMana().isEmpty(); + sa.setPredictedPayingColors(ComputerUtilMana.getConvergeColors(sa, player)); } // This is for playing spells regularly (no Cascade/Ripple etc.) diff --git a/forge-game/src/main/java/forge/game/mana/ManaCostBeingPaid.java b/forge-game/src/main/java/forge/game/mana/ManaCostBeingPaid.java index 776ef781cca6..7fa1dbb2b5c1 100644 --- a/forge-game/src/main/java/forge/game/mana/ManaCostBeingPaid.java +++ b/forge-game/src/main/java/forge/game/mana/ManaCostBeingPaid.java @@ -152,10 +152,6 @@ public Map getXManaCostPaidByColor() { return xManaCostPaidByColor; } - public final int getSunburst() { - return ColorSet.fromMask(sunburstMap).countColors(); - } - public final byte getColorsPaid() { return sunburstMap; } diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java index 46141e1bbdb7..da3373454893 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java @@ -129,6 +129,7 @@ public static class EmptySa extends SpellAbility { protected ApiType api = null; private List payingMana = Lists.newArrayList(); + private byte predictedPayingColors = 0; private List paidAbilities = Lists.newArrayList(); private Integer xManaCostPaid = null; private TreeBasedTable paidLists = TreeBasedTable.create(); @@ -905,13 +906,22 @@ public void run() { } public ColorSet getPayingColors() { - byte colors = 0; + byte colors = predictedPayingColors; for (Mana m : payingMana) { colors |= m.getColor(); } return ColorSet.fromMask(colors); } + /** + * Colors this spell is expected to be paid with, for deciding whether to cast it at all. Set + * while nothing has been spent yet, so that the color a card reads off its own payment + * (Converge, ManaColorsPaid, ManaSpent) can be answered before the payment exists. + */ + public final void setPredictedPayingColors(byte colors) { + predictedPayingColors = colors; + } + public List getPayingManaAbilities() { return paidAbilities; } From 8353ed2577cfbf150a9530b679bc302254cbc78d Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 13:09:43 -0600 Subject: [PATCH 06/13] AI: reuse the converge colours the X search already found Announcing X on a converge card runs a payment simulation per candidate X; predictConvergePayment then ran one more to read back the colours that X had just bought. setXForBestConverge now returns the winning mask and setMaxXValue records it, so the extra pass is gone - measured 13 -> 12 simulations casting Chamber Sentry off five lands. Recording it there means the ~60 setMaxXValue callers can set the prediction, not just canPlayAndPayFor, which is the only place that clears it. So getPayingColors now consults the prediction only while nothing has been spent; any real payment wins outright. That also covers the simulation AI, which announces X through SpellAbilityChoicesIterator and never goes through canPlayAndPayFor at all. Co-Authored-By: Claude Opus 5 --- forge-ai/src/main/java/forge/ai/AiController.java | 5 +++-- .../src/main/java/forge/ai/ComputerUtilCost.java | 2 +- .../src/main/java/forge/ai/ComputerUtilMana.java | 14 +++++++++----- .../java/forge/game/spellability/SpellAbility.java | 12 ++++++++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index c813468fc8a5..f36cefa02555 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -855,10 +855,11 @@ private void predictConvergePayment(final SpellAbility sa, final Card host) { return; } if (sa.costHasManaX()) { - // announce X first - on these cards it is what buys the colors being measured + // on these cards X is what buys the colors, so announcing it settles them too ComputerUtilCost.setMaxXValue(sa, player, sa.isTrigger()); + } else { + sa.setPredictedPayingColors(ComputerUtilMana.getConvergeColors(sa, player)); } - sa.setPredictedPayingColors(ComputerUtilMana.getConvergeColors(sa, player)); } // This is for playing spells regularly (no Cascade/Ripple etc.) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java index c796c6f6969a..00b19daf874c 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java @@ -738,7 +738,7 @@ public static int setMaxXValue(SpellAbility sa, Player ai, final boolean effect) // on a converge or sunburst card X only buys colors, so the useful announcement is the // least X that reaches the most of them rather than all the mana the AI can find. The // return value still reports what it could afford. - ComputerUtilMana.setXForBestConverge(sa, ai, x); + sa.setPredictedPayingColors(ComputerUtilMana.setXForBestConverge(sa, ai, x)); } return x; } diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index 787d09751a20..79aa9b316ea7 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -94,20 +94,24 @@ public static byte getConvergeColors(final SpellAbility sa, final Player ai) { /** * 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. + * that still reaches the most of them. Returns the colors the announced X buys. */ - public static void setXForBestConverge(final SpellAbility sa, final Player ai, final int maxX) { + public static byte setXForBestConverge(final SpellAbility sa, final Player ai, final int maxX) { int bestX = 0; - int bestColors = 0; + int bestCount = 0; + byte bestColors = 0; for (int i = 0; i <= maxX; i++) { sa.setXManaCostPaid(i); - int colors = getConvergeCount(sa, ai); - if (colors > bestColors) { + byte colors = getConvergeColors(sa, ai); + int count = ColorSet.fromMask(colors).countColors(); + if (count > bestCount) { + bestCount = count; 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. diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java index da3373454893..dec902a89ae3 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java @@ -906,7 +906,10 @@ public void run() { } public ColorSet getPayingColors() { - byte colors = predictedPayingColors; + if (payingMana.isEmpty()) { + return ColorSet.fromMask(predictedPayingColors); + } + byte colors = 0; for (Mana m : payingMana) { colors |= m.getColor(); } @@ -914,9 +917,10 @@ public ColorSet getPayingColors() { } /** - * Colors this spell is expected to be paid with, for deciding whether to cast it at all. Set - * while nothing has been spent yet, so that the color a card reads off its own payment - * (Converge, ManaColorsPaid, ManaSpent) can be answered before the payment exists. + * Colors this spell is expected to be paid with, for deciding whether to cast it at all. Only + * consulted while nothing has been spent, so that the color a card reads off its own payment + * (Converge, ManaColorsPaid, ManaSpent) can be answered before the payment exists; once any + * real mana is spent that is what counts. */ public final void setPredictedPayingColors(byte colors) { predictedPayingColors = colors; From 52d2e12a07682cab84851c466c6b08806bc533be Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 2 Aug 2026 15:02:15 -0600 Subject: [PATCH 07/13] AI: offer the converge prediction through PlayerController instead An alternative to the two commits before it, for the "AI only stuff in the rules engine" concern - these are not meant to both land, so drop whichever you do not want. Nothing is parked on the spell. getPayingColors asks the activating player's controller what it means to spend while nothing has been spent; PlayerController answers zero, so a human is unchanged, and PlayerControllerAi forwards to what AiController worked out. One seam still serves Converge, ManaColorsPaid and both ManaSpent checks, and there is no mask left to go stale. Scoped twice: canPlayAndPayFor hands back whatever the outer spell was expecting, so a nested evaluation cannot lose the outer prediction, and chooseSpellAbilityToPlay resets it next to AiCache and predictedCombat. HELD_MANA_SOURCES_FOR_NEXT_SPELL four lines above is the same species of state - good for one spell, kept AI side, reset at priority. It costs more than the field it replaces, and the prediction becomes controller-scoped rather than spell-scoped, so a runWithController swap loses it. Both cases degrade to zero, which is the answer before any of this. Co-Authored-By: Claude Opus 5 --- .../src/main/java/forge/ai/AiController.java | 32 ++++++++++++++++--- .../main/java/forge/ai/ComputerUtilCost.java | 2 +- .../main/java/forge/ai/ComputerUtilMana.java | 11 +++++++ .../java/forge/ai/PlayerControllerAi.java | 5 +++ .../forge/game/player/PlayerController.java | 9 ++++++ .../forge/game/spellability/SpellAbility.java | 18 ++++------- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index f36cefa02555..57ed5c620765 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -100,6 +100,8 @@ public class AiController { private boolean useLivingEnd; private List skipped; private volatile boolean timeoutReached; + private SpellAbility expectedPayingColorsSa; + private byte expectedPayingColors; public AiController(final Player computerPlayer, final Game game0) { player = computerPlayer; @@ -814,6 +816,9 @@ public boolean reserveManaSources(SpellAbility sa, PhaseType phaseType, boolean private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { final Card host = sa.getHostCard(); Card altHost = host; + // an evaluation can reach another one, so hand back whatever the outer spell was expecting + final SpellAbility outerColorsSa = expectedPayingColorsSa; + final byte outerColors = expectedPayingColors; if (sa instanceof Spell sp) { altHost = sp.canPlayFromHost(); @@ -839,7 +844,7 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { if (sa.isSpell()) { altHost.setCastSA(null); - sa.setPredictedPayingColors((byte) 0); + setExpectedPayingColors(outerColorsSa, outerColors); } return decision; @@ -847,8 +852,9 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { /** * Converge counts the colors actually spent, which nothing has yet - so Count$Converge would - * report zero and the AI would size every converge effect as if it were empty. The cast SA is - * already borrowed above for the same reason; tell it which colors it is about to spend too. + * report zero and the AI would size every converge effect as if it were empty. Work out what + * this spell would be paid with, so the card can be asked about its own payment while we are + * still deciding whether to cast it. */ private void predictConvergePayment(final SpellAbility sa, final Card host) { if (!host.hasConverge() || !sa.getPayingMana().isEmpty()) { @@ -858,10 +864,24 @@ private void predictConvergePayment(final SpellAbility sa, final Card host) { // on these cards X is what buys the colors, so announcing it settles them too ComputerUtilCost.setMaxXValue(sa, player, sa.isTrigger()); } else { - sa.setPredictedPayingColors(ComputerUtilMana.getConvergeColors(sa, player)); + setExpectedPayingColors(sa, ComputerUtilMana.getConvergeColors(sa, player)); } } + /** + * Remember what a spell we have not paid for yet would be paid with. Answered back through + * PlayerController, so the prediction never has to be parked on the spell itself. + */ + public void setExpectedPayingColors(final SpellAbility sa, final byte colors) { + expectedPayingColorsSa = sa; + expectedPayingColors = colors; + } + + public byte getExpectedPayingColors(final SpellAbility sa) { + // deliberately identity - the answer is only good for the exact spell it was worked out for + return sa == expectedPayingColorsSa ? expectedPayingColors : 0; + } + // This is for playing spells regularly (no Cascade/Ripple etc.) private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) { final Card host = sa.getHostCard(); @@ -1384,6 +1404,10 @@ public List chooseSpellAbilityToPlay() { // Reset priority mana reservation that's meant to work for one spell only memory.clearMemorySet(AiCardMemory.MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL); + // Same for what a spell was expected to be paid with, which is only good while we are + // deciding on that one spell + setExpectedPayingColors(null, (byte) 0); + if (usesFullSimulation()) { return singleSpellAbilityList(simPicker.chooseSpellAbilityToPlay(null)); } diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java index 00b19daf874c..c796c6f6969a 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java @@ -738,7 +738,7 @@ public static int setMaxXValue(SpellAbility sa, Player ai, final boolean effect) // on a converge or sunburst card X only buys colors, so the useful announcement is the // least X that reaches the most of them rather than all the mana the AI can find. The // return value still reports what it could afford. - sa.setPredictedPayingColors(ComputerUtilMana.setXForBestConverge(sa, ai, x)); + ComputerUtilMana.setXForBestConverge(sa, ai, x); } return x; } diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index 79aa9b316ea7..ffd3599ede04 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -111,9 +111,20 @@ public static byte setXForBestConverge(final SpellAbility sa, final Player ai, f } } sa.setXManaCostPaid(bestX); + rememberExpectedPayingColors(ai, sa, bestColors); return bestColors; } + /** + * Park what this spell would be paid with on the AI, where the rules side can ask for it + * through PlayerController rather than the AI having to write it onto the spell. + */ + private static void rememberExpectedPayingColors(final Player ai, final SpellAbility sa, final byte colors) { + if (ai != null && ai.getController() instanceof PlayerControllerAi controller) { + controller.getAi().setExpectedPayingColors(sa, colors); + } + } + // 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/PlayerControllerAi.java b/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java index 659674c878a6..7321e3321d14 100644 --- a/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java +++ b/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java @@ -85,6 +85,11 @@ public AiController getAi() { return brains; } + @Override + public byte getExpectedPayingColors(SpellAbility sa) { + return brains.getExpectedPayingColors(sa); + } + @Override public boolean isAI() { return true; diff --git a/forge-game/src/main/java/forge/game/player/PlayerController.java b/forge-game/src/main/java/forge/game/player/PlayerController.java index 192ef8737ecd..0ad691f86213 100644 --- a/forge-game/src/main/java/forge/game/player/PlayerController.java +++ b/forge-game/src/main/java/forge/game/player/PlayerController.java @@ -286,6 +286,15 @@ public boolean addKeywordCost(SpellAbility sa, Cost cost, KeywordInterface keywo return chooseNumberForKeywordCost(sa, cost, keyword, prompt, 1) == 1; } + /** + * Colors this controller means to spend on a spell it has not paid for yet, as a color mask. + * Only asked while nothing has been spent. Zero - the answer for a human, who pays by tapping + * rather than by deciding up front - leaves the card reading its own payment as empty. + */ + public byte getExpectedPayingColors(SpellAbility sa) { + return 0; + } + public abstract int chooseNumber(SpellAbility sa, String title, int min, int max); public abstract int chooseNumber(SpellAbility sa, String title, List values, Player relatedPlayer); public int chooseNumber(SpellAbility sa, String string, int min, int max, Map params) { diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java index dec902a89ae3..b06b9e02af4e 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java @@ -129,7 +129,6 @@ public static class EmptySa extends SpellAbility { protected ApiType api = null; private List payingMana = Lists.newArrayList(); - private byte predictedPayingColors = 0; private List paidAbilities = Lists.newArrayList(); private Integer xManaCostPaid = null; private TreeBasedTable paidLists = TreeBasedTable.create(); @@ -907,7 +906,12 @@ public void run() { public ColorSet getPayingColors() { if (payingMana.isEmpty()) { - return ColorSet.fromMask(predictedPayingColors); + // nothing spent yet, so ask whoever is holding this spell what they mean to spend - it + // is the only answer available to a card that reads its own payment (Converge, + // ManaColorsPaid, ManaSpent) while its controller is still deciding whether to cast it + final Player activator = getActivatingPlayer(); + return ColorSet.fromMask(activator == null ? 0 + : activator.getController().getExpectedPayingColors(this)); } byte colors = 0; for (Mana m : payingMana) { @@ -916,16 +920,6 @@ public ColorSet getPayingColors() { return ColorSet.fromMask(colors); } - /** - * Colors this spell is expected to be paid with, for deciding whether to cast it at all. Only - * consulted while nothing has been spent, so that the color a card reads off its own payment - * (Converge, ManaColorsPaid, ManaSpent) can be answered before the payment exists; once any - * real mana is spent that is what counts. - */ - public final void setPredictedPayingColors(byte colors) { - predictedPayingColors = colors; - } - public List getPayingManaAbilities() { return paidAbilities; } From 6c929fd23738e0f6a3bc886fe8d628d6c2e37302 Mon Sep 17 00:00:00 2001 From: liamiak Date: Wed, 5 Aug 2026 19:56:08 -0600 Subject: [PATCH 08/13] Drop getConvergeCount now that the prediction answers for it The four AI callers all had the same shape: work out the amount with calculateAmount, then override it for Count$Converge. That override existed because calculateAmount returned zero before anything had been paid, which is the problem this branch set out to fix. With the prediction in place the engine's own expression already gives the right number, so the override is computing a full payment simulation to arrive at a value the line above it already holds. Checked rather than assumed: instrumenting the Count$Converge branch in AbilityUtils and tagging each call with its caller shows 21 of them arriving via ChangeZoneAi.hiddenOriginCanPlayAI - the AI deciding whether to cast, not resolution - with castSA set and the count correct. So calculateAmount is answering properly while the AI is still choosing. PermanentAi's converge loop was already replaced by setXForBestConverge earlier on this branch, so removing these four leaves getConvergeCount with no callers at all and it goes too. 338 tests, 0 failures. Co-Authored-By: Claude Opus 5 --- forge-ai/src/main/java/forge/ai/ComputerUtilMana.java | 7 ------- forge-ai/src/main/java/forge/ai/ability/DamageAllAi.java | 3 --- forge-ai/src/main/java/forge/ai/ability/DrawAi.java | 8 +------- forge-ai/src/main/java/forge/ai/ability/TokenAi.java | 3 --- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index ffd3599ede04..aa759ec46cc0 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -74,13 +74,6 @@ private static boolean payManaCost(final Cost cost, final SpellAbility sa, final return payManaCost(manaCost, sa, ai, test, checkPlayable, effect) != null; } - /** - * Return the number of colors used for payment for Converge - */ - public static int getConvergeCount(final SpellAbility sa, final Player ai) { - return ColorSet.fromMask(getConvergeColors(sa, ai)).countColors(); - } - /** * Return the colors that would be used for payment, as a color mask. */ diff --git a/forge-ai/src/main/java/forge/ai/ability/DamageAllAi.java b/forge-ai/src/main/java/forge/ai/ability/DamageAllAi.java index ff634dac546a..9460d1219901 100644 --- a/forge-ai/src/main/java/forge/ai/ability/DamageAllAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/DamageAllAi.java @@ -28,9 +28,6 @@ protected AiAbilityDecision checkApiLogic(Player ai, SpellAbility sa) { int x = -1; final String damage = sa.getParam("NumDmg"); int dmg = AbilityUtils.calculateAmount(source, damage, sa); - if (damage.equals("X") && sa.getSVar(damage).equals("Count$Converge")) { - dmg = ComputerUtilMana.getConvergeCount(sa, ai); - } if (damage.equals("X") && sa.getSVar(damage).equals("Count$xPaid")) { x = ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger()); } diff --git a/forge-ai/src/main/java/forge/ai/ability/DrawAi.java b/forge-ai/src/main/java/forge/ai/ability/DrawAi.java index c692af6d3ff7..98f9c0b5bc65 100644 --- a/forge-ai/src/main/java/forge/ai/ability/DrawAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/DrawAi.java @@ -198,11 +198,7 @@ private boolean canLoot(Player ai, SpellAbility sa) { if (sa.hasParam("NumCards")) { String numDrawStr = sa.getParam("NumCards"); - if (numDrawStr.equals("X") && sa.getSVar(numDrawStr).equals("Count$Converge")) { - numDraw = ComputerUtilMana.getConvergeCount(sa, ai); - } else { - numDraw = AbilityUtils.calculateAmount(source, numDrawStr, sa); - } + numDraw = AbilityUtils.calculateAmount(source, numDrawStr, sa); } int numDiscard = 1; if (sub.hasParam("NumCards")) { @@ -274,8 +270,6 @@ private boolean targetAI(final Player ai, final SpellAbility sa, final boolean m assumeSafeX = true; } xPaid = true; - } else if (sa.getSVar(num).equals("Count$Converge")) { - numCards = ComputerUtilMana.getConvergeCount(sa, ai); } } 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..abfbb2813393 100644 --- a/forge-ai/src/main/java/forge/ai/ability/TokenAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/TokenAi.java @@ -84,9 +84,6 @@ protected boolean checkPhaseRestrictions(final Player ai, final SpellAbility sa, // X-cost spells if (tokenHasX) { int x = AbilityUtils.calculateAmount(sa.getHostCard(), tokenAmount, sa); - if (source.getSVar("X").equals("Count$Converge")) { - x = ComputerUtilMana.getConvergeCount(sa, ai); - } if (sa.getSVar("X").equals("Count$xPaid")) { x = ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger()); sa.getRootAbility().setXManaCostPaid(x); From 324d46757bf42f1b7a14ae80527dc3667b07ddf0 Mon Sep 17 00:00:00 2001 From: liamiak Date: Wed, 5 Aug 2026 20:32:03 -0600 Subject: [PATCH 09/13] Cover the draw path the override used to handle Painful Truths is a Count$Converge draw spell the AI will actually cast, so it exercises the path DrawAi's override used to serve. Five lands, a 2B spell, so the best it can reach is three colours - it draws three and pays three life. Co-Authored-By: Claude Opus 5 --- .../ai/ability/ConvergePredictionTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java index 0a7d61480b47..98b4cf2cc5ae 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java @@ -78,4 +78,30 @@ public void convergeSizesTheSpellThatBoughtTheColours() { assertNotNull("the AI cast Chamber Sentry", sentry); assertEquals("sunburst counted all five colours", 5, sentry.getNetToughness()); } + + /** + * DrawAi used to override calculateAmount for Count$Converge because it read zero before + * anything had been paid. It reads the prediction now, so the override is gone and the spell + * still has to size itself correctly. + */ + @Test + public void convergeSizesADrawSpellWithoutTheAiOverride() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + for (String l : new String[]{"Plains", "Island", "Swamp", "Mountain", "Forest"}) { + addCard(l, ai); + } + fillLibrary(ai, 20); + // 2B, so the best it can do is black plus two other colours + addCardToZone("Painful Truths", ai, ZoneType.Hand); + + final int lifeBefore = ai.getLife(); + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, ai); + game.getAction().checkStateEffects(true); + gameLoopUntilNextPhase(game); + + assertEquals("drew one card per colour spent", 3, ai.getCardsIn(ZoneType.Hand).size()); + assertEquals("and paid the same in life", 3, lifeBefore - ai.getLife()); + } } From edbcd461a56d60701b6706b0f50eddcf67d729bb Mon Sep 17 00:00:00 2001 From: liamiak Date: Fri, 14 Aug 2026 09:12:02 -0600 Subject: [PATCH 10/13] Cover the token path the converge loop never reached Sweep the Skies announces X from one SVar and reads Count$Converge from another, so neither the per-API override nor PermanentAi's loop sizes it. It gets one thopter instead of five without the prediction. Co-Authored-By: Claude Opus 5 --- .../ai/ability/ConvergePredictionTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java index 98b4cf2cc5ae..57fedea14fd7 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java @@ -79,6 +79,32 @@ public void convergeSizesTheSpellThatBoughtTheColours() { assertEquals("sunburst counted all five colours", 5, sentry.getNetToughness()); } + /** + * The token count is a separate SVar from the one X announces, so the per-API override never + * matched here and PermanentAi's loop is not on this path. Only the prediction sizes it. + */ + @Test + public void convergeSizesATokenSpellOnTheXPath() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + for (String l : new String[]{"Plains", "Island", "Swamp", "Mountain", "Forest"}) { + addCard(l, ai); + } + addCard("Island", ai); + addCard("Island", ai); + fillLibrary(ai, 15); + // X U U, and TokenAmount reads Count$Converge off a different SVar than X does + addCardToZone("Sweep the Skies", ai, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, ai); + game.getAction().checkStateEffects(true); + gameLoopUntilNextPhase(game); + + assertEquals("one thopter per colour spent", 5, + countCardsWithName(game, "Thopter Token", ZoneType.Battlefield)); + } + /** * DrawAi used to override calculateAmount for Count$Converge because it read zero before * anything had been paid. It reads the prediction now, so the override is gone and the spell From ee59488f6553ec64f69491a1e6e605f715d28447 Mon Sep 17 00:00:00 2001 From: liamiak Date: Fri, 14 Aug 2026 09:12:09 -0600 Subject: [PATCH 11/13] Search the converge X once per decision, not once per caller The same spell reaches setMaxXValue twice on the way to a decision - once from the prediction, once from its own API logic - and each walk solves the payment at every X. Chamber Sentry with nine lands spent 20 solves to answer the same question twice; it now spends 6. The walk also ran to the affordable maximum after the answer could no longer improve, so it stops once all five colors are bought. Co-Authored-By: Claude Opus 5 --- .../src/main/java/forge/ai/AiController.java | 25 ++++++++++++++++++- .../main/java/forge/ai/ComputerUtilMana.java | 25 ++++++++++++------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 57ed5c620765..f5b69cb17ddb 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -102,6 +102,7 @@ public class AiController { private volatile boolean timeoutReached; private SpellAbility expectedPayingColorsSa; private byte expectedPayingColors; + private int expectedConvergeX = -1; public AiController(final Player computerPlayer, final Game game0) { player = computerPlayer; @@ -819,6 +820,7 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { // an evaluation can reach another one, so hand back whatever the outer spell was expecting final SpellAbility outerColorsSa = expectedPayingColorsSa; final byte outerColors = expectedPayingColors; + final int outerConvergeX = expectedConvergeX; if (sa instanceof Spell sp) { altHost = sp.canPlayFromHost(); @@ -844,7 +846,7 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { if (sa.isSpell()) { altHost.setCastSA(null); - setExpectedPayingColors(outerColorsSa, outerColors); + rememberConvergeX(outerColorsSa, outerConvergeX, outerColors); } return decision; @@ -875,6 +877,7 @@ private void predictConvergePayment(final SpellAbility sa, final Card host) { public void setExpectedPayingColors(final SpellAbility sa, final byte colors) { expectedPayingColorsSa = sa; expectedPayingColors = colors; + expectedConvergeX = -1; } public byte getExpectedPayingColors(final SpellAbility sa) { @@ -882,6 +885,26 @@ public byte getExpectedPayingColors(final SpellAbility sa) { return sa == expectedPayingColorsSa ? expectedPayingColors : 0; } + /** + * Same, for a spell whose X is what buys the colors, so the announcement is remembered with + * them - searching for it walks every X up to the affordable one and solves the payment at each. + */ + public void rememberConvergeX(final SpellAbility sa, final int x, final byte colors) { + setExpectedPayingColors(sa, colors); + expectedConvergeX = x; + } + + /** + * Put back the X a previous search on this same spell settled on, if there was one. + */ + public boolean reapplyConvergeX(final SpellAbility sa) { + if (sa != expectedPayingColorsSa || expectedConvergeX < 0) { + return false; + } + sa.setXManaCostPaid(expectedConvergeX); + return true; + } + // This is for playing spells regularly (no Cascade/Ripple etc.) private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) { final Card host = sa.getHostCard(); diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index aa759ec46cc0..7cbde767e816 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -90,6 +90,13 @@ public static byte getConvergeColors(final SpellAbility sa, final Player ai) { * that still reaches the most of them. Returns the colors the announced X buys. */ public static byte setXForBestConverge(final SpellAbility sa, final Player ai, final int maxX) { + final AiController aic = aiControllerOf(ai); + if (aic != null && aic.reapplyConvergeX(sa)) { + // the same spell is searched again on the way down to its API logic, and each step of + // the walk is a full payment solve - the answer from the first walk still holds + return aic.getExpectedPayingColors(sa); + } + int bestX = 0; int bestCount = 0; byte bestColors = 0; @@ -101,21 +108,21 @@ public static byte setXForBestConverge(final SpellAbility sa, final Player ai, f bestCount = count; bestColors = colors; bestX = i; + if (bestCount == MagicColor.WUBRG.length) { + break; // nothing above this can buy a sixth color + } } } sa.setXManaCostPaid(bestX); - rememberExpectedPayingColors(ai, sa, bestColors); + if (aic != null) { + aic.rememberConvergeX(sa, bestX, bestColors); + } return bestColors; } - /** - * Park what this spell would be paid with on the AI, where the rules side can ask for it - * through PlayerController rather than the AI having to write it onto the spell. - */ - private static void rememberExpectedPayingColors(final Player ai, final SpellAbility sa, final byte colors) { - if (ai != null && ai.getController() instanceof PlayerControllerAi controller) { - controller.getAi().setExpectedPayingColors(sa, colors); - } + private static AiController aiControllerOf(final Player ai) { + return ai != null && ai.getController() instanceof PlayerControllerAi controller + ? controller.getAi() : null; } // Does not check if mana sources can be used right now, just checks for potential chance. From d736d02dd4ea418a88ed40ed8b6a985d1faaaefa Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 16 Aug 2026 12:48:08 -0600 Subject: [PATCH 12/13] AI: work out the converge payment on first read, not up front The prediction ran in canPlayAndPayFor, before any API logic, so every converge spell paid for a payment solve even when a cheap check would reject it. It is worked out on the first read instead and remembered by spell identity, which also removes the snapshot and restore that only existed because the eager call mutated state ahead of a nested evaluation. Co-Authored-By: Claude Opus 5 --- .../src/main/java/forge/ai/AiController.java | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index f5b69cb17ddb..ec6709d57497 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -103,6 +103,7 @@ public class AiController { private SpellAbility expectedPayingColorsSa; private byte expectedPayingColors; private int expectedConvergeX = -1; + private boolean solvingPayingColors; public AiController(final Player computerPlayer, final Game game0) { player = computerPlayer; @@ -817,10 +818,6 @@ public boolean reserveManaSources(SpellAbility sa, PhaseType phaseType, boolean private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { final Card host = sa.getHostCard(); Card altHost = host; - // an evaluation can reach another one, so hand back whatever the outer spell was expecting - final SpellAbility outerColorsSa = expectedPayingColorsSa; - final byte outerColors = expectedPayingColors; - final int outerConvergeX = expectedConvergeX; if (sa instanceof Spell sp) { altHost = sp.canPlayFromHost(); @@ -828,7 +825,6 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { return AiPlayDecision.CantPlaySa; } altHost.setCastSA(sa); - predictConvergePayment(sa, altHost); } else if (!sa.canPlay()) { return AiPlayDecision.CantPlaySa; } @@ -846,30 +842,11 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { if (sa.isSpell()) { altHost.setCastSA(null); - rememberConvergeX(outerColorsSa, outerConvergeX, outerColors); } return decision; } - /** - * Converge counts the colors actually spent, which nothing has yet - so Count$Converge would - * report zero and the AI would size every converge effect as if it were empty. Work out what - * this spell would be paid with, so the card can be asked about its own payment while we are - * still deciding whether to cast it. - */ - private void predictConvergePayment(final SpellAbility sa, final Card host) { - if (!host.hasConverge() || !sa.getPayingMana().isEmpty()) { - return; - } - if (sa.costHasManaX()) { - // on these cards X is what buys the colors, so announcing it settles them too - ComputerUtilCost.setMaxXValue(sa, player, sa.isTrigger()); - } else { - setExpectedPayingColors(sa, ComputerUtilMana.getConvergeColors(sa, player)); - } - } - /** * Remember what a spell we have not paid for yet would be paid with. Answered back through * PlayerController, so the prediction never has to be parked on the spell itself. @@ -880,9 +857,32 @@ public void setExpectedPayingColors(final SpellAbility sa, final byte colors) { expectedConvergeX = -1; } + /** + * Converge counts the colors actually spent, which nothing has yet - so Count$Converge would + * report zero and the AI would size every converge effect as if it were empty. Worked out on + * the first read rather than up front, so a spell rejected by a cheap check never pays for a + * payment solve, and remembered by spell identity because it is only good for that one spell. + */ public byte getExpectedPayingColors(final SpellAbility sa) { - // deliberately identity - the answer is only good for the exact spell it was worked out for - return sa == expectedPayingColorsSa ? expectedPayingColors : 0; + if (sa == expectedPayingColorsSa) { + return expectedPayingColors; + } + final Card host = sa.getHostCard(); + if (solvingPayingColors || host == null || !host.hasConverge() + || !sa.getPayingMana().isEmpty()) { + return 0; + } + if (sa.costHasManaX()) { + // on these cards X is what buys the colors, so they are settled by the announcement + return 0; + } + solvingPayingColors = true; + try { + setExpectedPayingColors(sa, ComputerUtilMana.getConvergeColors(sa, player)); + } finally { + solvingPayingColors = false; + } + return expectedPayingColors; } /** From c870505a3ee963d8f8452d07bbd29b3e5910c885 Mon Sep 17 00:00:00 2001 From: liamiak Date: Sun, 16 Aug 2026 12:48:09 -0600 Subject: [PATCH 13/13] AI: announce X for a converge token count that reads it through another SVar TokenAi only announces X when TokenAmount is literally "X". Sweep the Skies reads the count off a separate SVar, so nothing announced its X and it made one thopter off any board. The announcement has to precede spawnToken, which is the first thing that can read the count, and setMaxXValue already settles on the converge-best X - keeping its return value would put the largest affordable X back over it. Co-Authored-By: Claude Opus 5 --- forge-ai/src/main/java/forge/ai/ability/TokenAi.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 abfbb2813393..7515f55ffc23 100644 --- a/forge-ai/src/main/java/forge/ai/ability/TokenAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/TokenAi.java @@ -67,6 +67,15 @@ protected boolean checkPhaseRestrictions(final Player ai, final SpellAbility sa, } } + // a converge token count reads the payment through its own SVar, so nothing below would + // announce X - and the count is read before that block, so it has to happen here + if (sa.costHasManaX() && source.hasConverge() && sa.getSVar("X").equals("Count$xPaid") + && !"X".equals(sa.getParamOrDefault("TokenAmount", "1"))) { + // it announces the converge-best X itself; keeping the return value would put the + // largest affordable X back over it + ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger()); + } + Card actualToken = spawnToken(ai, sa); String tokenAmount = sa.getParamOrDefault("TokenAmount", "1");