diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 2625df42e3f1..ec6709d57497 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -100,6 +100,10 @@ public class AiController { private boolean useLivingEnd; private List skipped; private volatile boolean timeoutReached; + private SpellAbility expectedPayingColorsSa; + private byte expectedPayingColors; + private int expectedConvergeX = -1; + private boolean solvingPayingColors; public AiController(final Player computerPlayer, final Game game0) { player = computerPlayer; @@ -843,6 +847,64 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) { return decision; } + /** + * 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; + 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) { + 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; + } + + /** + * 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(); @@ -1365,6 +1427,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 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..7cbde767e816 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -75,16 +75,56 @@ private static boolean payManaCost(final Cost cost, final SpellAbility sa, final } /** - * Return the number of colors used for payment for Converge + * Return the colors that would be used for payment, as a color mask. */ - public static int getConvergeCount(final SpellAbility sa, final Player ai) { + 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. 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; + for (int i = 0; i <= maxX; i++) { + sa.setXManaCostPaid(i); + byte colors = getConvergeColors(sa, ai); + int count = ColorSet.fromMask(colors).countColors(); + if (count > bestCount) { + bestCount = count; + bestColors = colors; + bestX = i; + if (bestCount == MagicColor.WUBRG.length) { + break; // nothing above this can buy a sixth color + } + } + } + sa.setXManaCostPaid(bestX); + if (aic != null) { + aic.rememberConvergeX(sa, bestX, bestColors); + } + return bestColors; + } + + 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. 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-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-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/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()) { 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..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"); @@ -84,9 +93,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); 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/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 46141e1bbdb7..b06b9e02af4e 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java @@ -905,6 +905,14 @@ public void run() { } public ColorSet getPayingColors() { + if (payingMana.isEmpty()) { + // 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) { colors |= m.getColor(); 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..57fedea14fd7 --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/ai/ability/ConvergePredictionTest.java @@ -0,0 +1,133 @@ +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()); + } + + /** + * 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 + * 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()); + } +} 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.