From 869b5179ee92cca924411935e0b48a5edc3ce692 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Fri, 24 Apr 2026 23:44:45 -0700 Subject: [PATCH 1/8] Improve AI land destruction targeting with contextual land priority scoring. --- .../main/java/forge/ai/ComputerUtilCard.java | 232 ++++++++++++++++++ .../main/java/forge/ai/ability/DestroyAi.java | 27 +- 2 files changed, 251 insertions(+), 8 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index de5ecb59604..2318ee19caf 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -38,6 +38,7 @@ import forge.game.cost.Cost; import forge.game.cost.CostPayEnergy; import forge.game.cost.CostRemoveCounter; +import forge.game.cost.CostSacrifice; import forge.game.cost.CostUntap; import forge.game.keyword.Keyword; import forge.game.keyword.KeywordCollection; @@ -51,11 +52,19 @@ import forge.game.staticability.StaticAbility; import forge.game.staticability.StaticAbilityMode; import forge.game.trigger.Trigger; +import forge.game.trigger.TriggerType; import forge.game.zone.MagicStack; import forge.game.zone.ZoneType; import forge.item.PaperCard; public class ComputerUtilCard { + private static final List DANGEROUS_LANDS_TO_REMOVE = Arrays.asList( + "Dark Depths", + "Glacial Chasm", + "Valakut, the Molten Pinnacle", + "Maze of Ith" + ); + public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) { CardCollectionView all = list; if (targeted) { @@ -258,6 +267,229 @@ public static Card getBestLandAI(final Iterable list) { .orElseGet(() -> Aggregates.random(bLand)); // random tapped land of least represented type } + public static Card getBestLandToRemoveAI(final Player ai, final Iterable list, final SpellAbility removal) { + final List lands = CardLists.filter(list, CardPredicates.LANDS); + if (lands.isEmpty()) { + return null; + } + + return lands.stream() + .max(Comparator.comparingInt((Card c) -> evaluateLandRemovalPriority(ai, c, removal)) + .thenComparingInt(GameStateEvaluator::evaluateLand)) + .orElse(null); + } + + public static int evaluateLandRemovalPriority(final Player ai, final Card land, final SpellAbility removal) { + return evaluateLandRemovalPriority(ai, land, removal, true); + } + + private static int evaluateLandRemovalPriority(final Player ai, final Card land, final SpellAbility removal, + final boolean includeLandDestruction) { + if (land == null || !land.isLand()) { + return 0; + } + + int score = land.isBasicLand() ? 5 : 10; + + // High priority: lands that inherently generate extra mana, such as + // Temple of the False God, Nykthos, Shrine to Nyx, Lost Vale, and Three + // Tree City. Use unmultipled mana so global effects like Mana Flare do + // not make every basic land look like a Strip Mine target. + final int netMana = getIntrinsicNetMana(land); + if (netMana >= 2) { + score += 180 + 45 * (netMana - 2); + } + + for (Card aura : land.getEnchantedBy()) { + // High priority: an opponent's land enhanced by Wild Growth, + // Utopia Sprawl, Overgrowth, or similar mana-boosting Auras. + if (aura.getController().equals(land.getController()) && hasManaBoostingText(aura)) { + score += 160; + } + // High priority: remove the land hosting an On Thin Ice-style Aura + // when that Aura has removed one of this AI's permanents. + if (hasRemovedAiPermanent(ai, aura)) { + score += 180; + } + } + + boolean hasAnimationAbility = false; + for (SpellAbility ability : land.getNonManaAbilities()) { + if (ability.isLandAbility()) { + continue; + } + Cost cost = ability.getPayCosts(); + if (includeLandDestruction && isLandDestructionAbility(ability)) { + // High priority only when it cannot answer immediately: + // a tapped Strip Mine or Wasteland matters if the AI controls + // something worth protecting, but an untapped one can respond. + if (land.isTapped() && aiHasHighPriorityLand(ai)) { + score += 170; + } + continue; + } + if (isHomewardPathAbility(ability)) { + // Usually low priority: Homeward Path matters if the AI has + // stolen creatures that it could lose, but otherwise it is + // mostly just a colorless land with a narrow political button. + if (aiControlsStolenCreature(ai)) { + score += 90; + } + continue; + } + if (isLandAnimationAbility(ability)) { + hasAnimationAbility = true; + // Medium priority: manlands like Mishra's Factory and Mutavault. + // They become much more urgent while attacking the AI. + score += isAttackingAi(land, ai) ? 140 : 70; + } else if (cost != null && cost.hasSpecificCostType(CostSacrifice.class)) { + // Medium priority: one-shot utility lands such as Scavenger + // Grounds or Blast Zone are relevant, but usually not urgent. + score += 45; + } else if (cost != null && cost.hasTapCost()) { + // Medium priority: repeatable utility lands such as Bonders' + // Enclave, Kessig Wolf Run, Geode Grotto, or Oran-Rief. + score += 55; + } else { + // Medium-low priority: utility with no tap cost, including + // lands that alter play patterns without producing extra mana. + score += 45; + } + if (ability.getApi() == ApiType.Mana || hasManaSubAbility(ability)) { + // High priority: non-mana abilities that create mana, such as + // Nykthos-style choose-color abilities implemented in a sub-DB. + score += 150; + } + } + + if (land.isCreature() && !hasAnimationAbility) { + // Medium priority: already-animated manlands and lands that are + // naturally creatures. Manlands with their own animation ability + // were already scored above; this catches external animation. + score += isAttackingAi(land, ai) ? 140 : 55; + } + + if (isKnownDangerousLand(land)) { + // High priority: oddball lands whose danger is hard to infer from + // their generic ability shape, like Dark Depths or Glacial Chasm. + score += 170; + } + + // Medium priority: static/triggered utility such as Reliquary Tower, + // Valakut-style triggers, or prevention/attack restrictions. + score += Math.min(90, land.getStaticAbilities().size() * 45); + score += Math.min(90, land.getTriggers().size() * 45); + + return score; + } + + private static int getIntrinsicNetMana(final Card land) { + int maxProduced = 0; + for (SpellAbility mana : land.getManaAbilities()) { + mana.setActivatingPlayer(land.getController()); + int manaCost = mana.getPayCosts().getTotalMana().getCMC(); + maxProduced = Math.max(maxProduced, mana.amountOfManaGenerated(false) - manaCost); + } + return maxProduced; + } + + private static boolean hasManaBoostingText(final Card aura) { + for (String value : aura.getSVars().values()) { + if (value.contains("DB$ Mana") || value.contains("TapsForMana") || value.contains("ManaReflected")) { + return true; + } + } + for (Trigger trigger : aura.getTriggers()) { + if (TriggerType.TapsForMana.equals(trigger.getMode())) { + return true; + } + } + return false; + } + + private static boolean hasRemovedAiPermanent(final Player ai, final Card card) { + for (Card exiled : card.getExiledCards()) { + if (exiled.getOwner().equals(ai) && exiled.isPermanent()) { + return true; + } + } + for (Object remembered : card.getRemembered()) { + if (remembered instanceof Card rememberedCard + && rememberedCard.getOwner().equals(ai) + && rememberedCard.isPermanent()) { + return true; + } + } + return false; + } + + private static boolean isLandDestructionAbility(final SpellAbility ability) { + if (ability.getApi() != ApiType.Destroy && ability.getApi() != ApiType.ChangeZone) { + return false; + } + String valid = ability.getParamOrDefault("ValidTgts", ""); + if (valid.isEmpty()) { + valid = ability.getParamOrDefault("ValidCards", ""); + } + return valid.contains("Land"); + } + + private static boolean isHomewardPathAbility(final SpellAbility ability) { + return ability.getApi() == ApiType.GainControlVariant + && "GainControlOwns".equals(ability.getParam("AILogic")); + } + + private static boolean aiControlsStolenCreature(final Player ai) { + for (Card creature : ai.getCreaturesInPlay()) { + if (!creature.getOwner().equals(ai)) { + return true; + } + } + return false; + } + + private static boolean hasManaSubAbility(final SpellAbility ability) { + SpellAbility sub = ability.getSubAbility(); + while (sub != null) { + if (sub.getApi() == ApiType.Mana) { + return true; + } + sub = sub.getSubAbility(); + } + return false; + } + + private static boolean isLandAnimationAbility(final SpellAbility ability) { + if (ability.getApi() == ApiType.Animate) { + return true; + } + String description = ability.getDescription(); + return description != null && description.contains("becomes") && description.contains("creature"); + } + + private static boolean isAttackingAi(final Card land, final Player ai) { + Combat combat = land.getGame() == null ? null : land.getGame().getCombat(); + return combat != null && combat.isAttacking(land, ai); + } + + private static boolean aiHasHighPriorityLand(final Player ai) { + for (Card aiLand : ai.getLandsInPlay()) { + if (evaluateLandRemovalPriority(ai, aiLand, null, false) >= 150) { + return true; + } + } + return false; + } + + private static boolean isKnownDangerousLand(final Card land) { + for (String landName : DANGEROUS_LANDS_TO_REMOVE) { + if (land.getName().equals(landName)) { + return true; + } + } + return false; + } + /** *

* getWorstLand. diff --git a/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java b/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java index 8ea6e49793c..9c2bcc14b14 100644 --- a/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java @@ -227,7 +227,7 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa } } } else if (CardLists.getNotType(list, "Land").isEmpty()) { - choice = ComputerUtilCard.getBestLandAI(list); + choice = ComputerUtilCard.getBestLandToRemoveAI(ai, list, sa); if ("LandForLand".equals(logic) || "GhostQuarter".equals(logic)) { // Strip Mine, Wasteland - cut short if the relevant logic fails @@ -425,23 +425,34 @@ public boolean doLandForLandRemovalLogic(SpellAbility sa, Player ai, Card tgtLan boolean canColorLock = (oppSkippedLandDrop || oppLands.size() > 3) && tgtLand.isBasicLand() && CardLists.count(oppLands, CardPredicates.nameEquals(tgtLand.getName())) == 1; - // Non-basic lands are currently not ranked in any way in ComputerUtilCard#getBestLandAI, so if a non-basic land is best target, - // consider killing it off unless there's too much potential tempo loss. - // TODO: actually rank non-basics in that method and then kill off the potentially dangerous (manlands, Valakut) or lucrative - // (dual/triple mana that opens access to a certain color) lands - boolean nonBasicTgt = !tgtLand.isBasicLand(); + int targetPriority = ComputerUtilCard.evaluateLandRemovalPriority(ai, tgtLand, sa); + boolean mediumPriorityTgt = targetPriority >= 50; + boolean highPriorityTgt = targetPriority >= 150; // Try not to lose tempo too much and not to mana-screw yourself when considering this logic int numLandsInHand = CardLists.count(ai.getCardsIn(ZoneType.Hand), CardPredicates.LANDS_PRODUCING_MANA); int numLandsOTB = CardLists.count(ai.getCardsIn(ZoneType.Battlefield), CardPredicates.LANDS_PRODUCING_MANA); // If the opponent skipped a land drop, consider not looking at having the extra land in hand if the profile allows it - boolean isHighPriority = highPriorityIfNoLandDrop && oppSkippedLandDrop; + boolean isHighPriority = highPriorityTgt || (highPriorityIfNoLandDrop && oppSkippedLandDrop); - boolean timingCheck = canManaLock || canColorLock || nonBasicTgt; + boolean timingCheck = canManaLock || canColorLock || mediumPriorityTgt; boolean tempoCheck = numLandsOTB >= amountNoTempoCheck || ((numLandsInHand >= amountLandsInHand || isHighPriority) && ((numLandsInHand + numLandsOTB >= amountNoTimingCheck) || timingCheck)); + // Tectonic Edge, Strip Mine, and Wasteland should not cash in a large + // share of the AI's own mana base for a merely medium utility target. + boolean sacrificesSourceLand = sa.getHostCard().isLand() + && sa.getPayCosts() != null + && sa.getPayCosts().hasSpecificCostType(CostSacrifice.class); + if (sacrificesSourceLand && !highPriorityTgt && !canManaLock && !canColorLock && numLandsOTB <= 3) { + return false; + } + + if (!mediumPriorityTgt && ai.getGame().getPlayers().size() > 2 && !canManaLock && !canColorLock) { + return false; + } + // For Ghost Quarter, only use it if you have either more lands in play than your opponent // or the same number of lands but an extra land in hand (otherwise the AI plays too suboptimally) if ("GhostQuarter".equals(logic)) { From ebda3ba39badcc1dfc8db9f91439ce186edbffc6 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Sat, 25 Apr 2026 00:44:27 -0700 Subject: [PATCH 2/8] Added Nykthos to list of dangerous lands --- .../main/java/forge/ai/ComputerUtilCard.java | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index 2318ee19caf..d3cc4f899c9 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -1,17 +1,19 @@ package forge.ai; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; -import forge.StaticData; -import forge.ai.simulation.GameStateEvaluator; -import forge.card.mana.ManaCost; -import forge.game.card.*; -import forge.util.*; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.MutablePair; import org.apache.commons.lang3.tuple.Pair; @@ -20,12 +22,15 @@ import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import forge.StaticData; +import forge.ai.simulation.GameStateEvaluator; import forge.card.CardRules; import forge.card.CardStateName; import forge.card.CardType; import forge.card.ColorSet; import forge.card.MagicColor; import forge.card.MagicColor.Constant; +import forge.card.mana.ManaCost; import forge.deck.CardPool; import forge.deck.Deck; import forge.deck.DeckSection; @@ -33,6 +38,14 @@ import forge.game.GameObject; import forge.game.ability.AbilityUtils; import forge.game.ability.ApiType; +import forge.game.card.Card; +import forge.game.card.CardCollection; +import forge.game.card.CardCollectionView; +import forge.game.card.CardCopyService; +import forge.game.card.CardFactoryUtil; +import forge.game.card.CardLists; +import forge.game.card.CardPredicates; +import forge.game.card.CounterEnumType; import forge.game.combat.Combat; import forge.game.combat.CombatUtil; import forge.game.cost.Cost; @@ -56,13 +69,19 @@ import forge.game.zone.MagicStack; import forge.game.zone.ZoneType; import forge.item.PaperCard; +import forge.util.Aggregates; +import forge.util.Expressions; +import forge.util.IterableUtil; +import forge.util.MyRandom; +import forge.util.TextUtil; public class ComputerUtilCard { private static final List DANGEROUS_LANDS_TO_REMOVE = Arrays.asList( "Dark Depths", "Glacial Chasm", "Valakut, the Molten Pinnacle", - "Maze of Ith" + "Maze of Ith", + ", Shrine to Nyx" ); public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) { From c945e33ff40fe5ba483b3e8e10cd93e14eea33d1 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Sat, 25 Apr 2026 00:48:54 -0700 Subject: [PATCH 3/8] Added more dangerous lands --- .../src/main/java/forge/ai/ComputerUtilCard.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index d3cc4f899c9..b4e9d9332dd 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -81,7 +81,17 @@ public class ComputerUtilCard { "Glacial Chasm", "Valakut, the Molten Pinnacle", "Maze of Ith", - ", Shrine to Nyx" + "Nykthos, Shrine to Nyx", + "Three Tree City", + "Gaea's Cradle", + "Itlimoc, Cradle of the Sun", + "Tolarian Academy", + "Vault of Catlacan", + "Serra's Sanctum", + "Cabal Coffers", + "Cabal Stronghold", + "Urza's Workshop", + "Jurassic Park" ); public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) { From 9e65f42a8cdb69ec74c659e38fd45cc100ffe8d4 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Sat, 25 Apr 2026 00:55:35 -0700 Subject: [PATCH 4/8] Keeping the dangerous lands list simple --- forge-ai/src/main/java/forge/ai/ComputerUtilCard.java | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index b4e9d9332dd..3d2a4d7b235 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -82,16 +82,7 @@ public class ComputerUtilCard { "Valakut, the Molten Pinnacle", "Maze of Ith", "Nykthos, Shrine to Nyx", - "Three Tree City", - "Gaea's Cradle", - "Itlimoc, Cradle of the Sun", - "Tolarian Academy", - "Vault of Catlacan", - "Serra's Sanctum", - "Cabal Coffers", - "Cabal Stronghold", - "Urza's Workshop", - "Jurassic Park" + "Three Tree City" ); public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) { From fb6cfdb289b76a170d57f5a633d59d55813f37b0 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Sat, 25 Apr 2026 01:18:55 -0700 Subject: [PATCH 5/8] Used LandEvaluator as baseline for land removal priority --- .../main/java/forge/ai/ComputerUtilCard.java | 60 ++++++------------- 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index 3d2a4d7b235..6226ed32696 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -309,16 +309,12 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, return 0; } - int score = land.isBasicLand() ? 5 : 10; - - // High priority: lands that inherently generate extra mana, such as - // Temple of the False God, Nykthos, Shrine to Nyx, Lost Vale, and Three - // Tree City. Use unmultipled mana so global effects like Mana Flare do - // not make every basic land look like a Strip Mine target. - final int netMana = getIntrinsicNetMana(land); - if (netMana >= 2) { - score += 180 + 45 * (netMana - 2); - } + // Start with the existing land valuation and convert it into a + // removal priority baseline. A normal one-mana land is worth about 100 + // in LandEvaluator, so subtract that off to keep basics and simple + // MDFC lands low while preserving high scores for Gaea's Cradle, + // Tolarian Academy, Serra's Sanctum, Cabal Coffers, etc. + int score = Math.max(0, landEvaluator.apply(land) - 100); for (Card aura : land.getEnchantedBy()) { // High priority: an opponent's land enhanced by Wild Growth, @@ -353,7 +349,9 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, // stolen creatures that it could lose, but otherwise it is // mostly just a colorless land with a narrow political button. if (aiControlsStolenCreature(ai)) { - score += 90; + score += 100; + } else { + score = Math.max(0, score - 50); } continue; } @@ -365,20 +363,13 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, } else if (cost != null && cost.hasSpecificCostType(CostSacrifice.class)) { // Medium priority: one-shot utility lands such as Scavenger // Grounds or Blast Zone are relevant, but usually not urgent. - score += 45; - } else if (cost != null && cost.hasTapCost()) { - // Medium priority: repeatable utility lands such as Bonders' - // Enclave, Kessig Wolf Run, Geode Grotto, or Oran-Rief. - score += 55; - } else { - // Medium-low priority: utility with no tap cost, including - // lands that alter play patterns without producing extra mana. - score += 45; + score += 40; } if (ability.getApi() == ApiType.Mana || hasManaSubAbility(ability)) { - // High priority: non-mana abilities that create mana, such as - // Nykthos-style choose-color abilities implemented in a sub-DB. - score += 150; + // High priority: non-mana root abilities that create mana, + // such as Nykthos-style choose-color abilities implemented in + // a sub-DB. LandEvaluator sees these as utility, not big mana. + score += 100; } } @@ -390,29 +381,16 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, } if (isKnownDangerousLand(land)) { - // High priority: oddball lands whose danger is hard to infer from - // their generic ability shape, like Dark Depths or Glacial Chasm. - score += 170; + // High priority floor: oddball lands whose danger is hard to infer + // from their generic ability shape, like Dark Depths or Glacial + // Chasm. Do not add here, because some of these also get a good + // baseline score from LandEvaluator. + score = Math.max(score, 170); } - // Medium priority: static/triggered utility such as Reliquary Tower, - // Valakut-style triggers, or prevention/attack restrictions. - score += Math.min(90, land.getStaticAbilities().size() * 45); - score += Math.min(90, land.getTriggers().size() * 45); - return score; } - private static int getIntrinsicNetMana(final Card land) { - int maxProduced = 0; - for (SpellAbility mana : land.getManaAbilities()) { - mana.setActivatingPlayer(land.getController()); - int manaCost = mana.getPayCosts().getTotalMana().getCMC(); - maxProduced = Math.max(maxProduced, mana.amountOfManaGenerated(false) - manaCost); - } - return maxProduced; - } - private static boolean hasManaBoostingText(final Card aura) { for (String value : aura.getSVars().values()) { if (value.contains("DB$ Mana") || value.contains("TapsForMana") || value.contains("ManaReflected")) { From 64c4a948d7159b7fcc9f3afa15f21de029eb2f7b Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Sat, 25 Apr 2026 06:43:17 -0700 Subject: [PATCH 6/8] Aura schore effects moved to last --- .../main/java/forge/ai/ComputerUtilCard.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index 6226ed32696..b8465081a72 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -316,19 +316,6 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, // Tolarian Academy, Serra's Sanctum, Cabal Coffers, etc. int score = Math.max(0, landEvaluator.apply(land) - 100); - for (Card aura : land.getEnchantedBy()) { - // High priority: an opponent's land enhanced by Wild Growth, - // Utopia Sprawl, Overgrowth, or similar mana-boosting Auras. - if (aura.getController().equals(land.getController()) && hasManaBoostingText(aura)) { - score += 160; - } - // High priority: remove the land hosting an On Thin Ice-style Aura - // when that Aura has removed one of this AI's permanents. - if (hasRemovedAiPermanent(ai, aura)) { - score += 180; - } - } - boolean hasAnimationAbility = false; for (SpellAbility ability : land.getNonManaAbilities()) { if (ability.isLandAbility()) { @@ -388,6 +375,19 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, score = Math.max(score, 170); } + for (Card aura : land.getEnchantedBy()) { + // High priority: an opponent's land enhanced by Wild Growth, + // Utopia Sprawl, Overgrowth, or similar mana-boosting Auras. + if (aura.getController().equals(land.getController()) && hasManaBoostingText(aura)) { + score += 160; + } + // High priority: remove the land hosting an On Thin Ice-style Aura + // when that Aura has removed one of this AI's permanents. + if (hasRemovedAiPermanent(ai, aura)) { + score += 180; + } + } + return score; } From 6f0cef1aea1633111471a016096e57b3bea6dd9a Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Sun, 26 Apr 2026 13:33:54 -0700 Subject: [PATCH 7/8] Move known dangerous lands to card scripts --- .../main/java/forge/ai/ComputerUtilCard.java | 33 ++++--------------- forge-gui/res/cardsfolder/d/dark_depths.txt | 1 + forge-gui/res/cardsfolder/e/eye_of_ugin.txt | 1 + .../res/cardsfolder/f/field_of_the_dead.txt | 1 + forge-gui/res/cardsfolder/g/glacial_chasm.txt | 1 + forge-gui/res/cardsfolder/m/maze_of_ith.txt | 1 + .../cardsfolder/n/nykthos_shrine_to_nyx.txt | 1 + .../t/the_tabernacle_at_pendrell_vale.txt | 1 + .../res/cardsfolder/t/three_tree_city.txt | 1 + forge-gui/res/cardsfolder/u/urzas_saga.txt | 1 + .../v/valakut_the_molten_pinnacle.txt | 1 + 11 files changed, 17 insertions(+), 26 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index b8465081a72..c6f770bb5ff 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -76,15 +76,6 @@ import forge.util.TextUtil; public class ComputerUtilCard { - private static final List DANGEROUS_LANDS_TO_REMOVE = Arrays.asList( - "Dark Depths", - "Glacial Chasm", - "Valakut, the Molten Pinnacle", - "Maze of Ith", - "Nykthos, Shrine to Nyx", - "Three Tree City" - ); - public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) { CardCollectionView all = list; if (targeted) { @@ -294,8 +285,7 @@ public static Card getBestLandToRemoveAI(final Player ai, final Iterable l } return lands.stream() - .max(Comparator.comparingInt((Card c) -> evaluateLandRemovalPriority(ai, c, removal)) - .thenComparingInt(GameStateEvaluator::evaluateLand)) + .max(Comparator.comparingInt(c -> evaluateLandRemovalPriority(ai, c, removal))) .orElse(null); } @@ -367,12 +357,12 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, score += isAttackingAi(land, ai) ? 140 : 55; } - if (isKnownDangerousLand(land)) { - // High priority floor: oddball lands whose danger is hard to infer - // from their generic ability shape, like Dark Depths or Glacial - // Chasm. Do not add here, because some of these also get a good - // baseline score from LandEvaluator. - score = Math.max(score, 170); + if (land.hasSVar("AILandRemovalMinScore")) { + // Card-specific floor for lands whose danger is hard to infer from + // their generic ability shape, like Dark Depths or Nykthos. Keep it + // removal-specific so regular land play does not overvalue them. + score = Math.max(score, AbilityUtils.calculateAmount(land, + land.getSVar("AILandRemovalMinScore"), null)); } for (Card aura : land.getEnchantedBy()) { @@ -479,15 +469,6 @@ private static boolean aiHasHighPriorityLand(final Player ai) { return false; } - private static boolean isKnownDangerousLand(final Card land) { - for (String landName : DANGEROUS_LANDS_TO_REMOVE) { - if (land.getName().equals(landName)) { - return true; - } - } - return false; - } - /** *

* getWorstLand. diff --git a/forge-gui/res/cardsfolder/d/dark_depths.txt b/forge-gui/res/cardsfolder/d/dark_depths.txt index e2b63fc6c86..0e477c4c068 100644 --- a/forge-gui/res/cardsfolder/d/dark_depths.txt +++ b/forge-gui/res/cardsfolder/d/dark_depths.txt @@ -5,4 +5,5 @@ K:etbCounter:ICE:10 A:AB$ RemoveCounter | Cost$ 3 | CounterType$ ICE | CounterNum$ 1 | SpellDescription$ Remove an ice counter from CARDNAME. T:Mode$ Always | TriggerZones$ Battlefield | IsPresent$ Card.Self+counters_EQ0_ICE | Execute$ TrigToken | TriggerDescription$ When CARDNAME has no ice counters on it, sacrifice it. If you do, create Marit Lage, a legendary 20/20 black Avatar creature token with flying and indestructible. SVar:TrigToken:AB$ Token | TokenScript$ marit_lage | TokenOwner$ You | Cost$ Mandatory Sac<1/CARDNAME> +SVar:AILandRemovalMinScore:170 Oracle:Dark Depths enters with ten ice counters on it.\n{3}: Remove an ice counter from Dark Depths.\nWhen Dark Depths has no ice counters on it, sacrifice it. If you do, create Marit Lage, a legendary 20/20 black Avatar creature token with flying and indestructible. diff --git a/forge-gui/res/cardsfolder/e/eye_of_ugin.txt b/forge-gui/res/cardsfolder/e/eye_of_ugin.txt index 326f2684852..0d42199ee5c 100644 --- a/forge-gui/res/cardsfolder/e/eye_of_ugin.txt +++ b/forge-gui/res/cardsfolder/e/eye_of_ugin.txt @@ -3,6 +3,7 @@ ManaCost:no cost Types:Legendary Land S:Mode$ ReduceCost | ValidCard$ Eldrazi.Colorless | Type$ Spell | Activator$ You | Amount$ 2 | Description$ Colorless Eldrazi spells you cast cost {2} less to cast. A:AB$ ChangeZone | Cost$ 7 T | Origin$ Library | Destination$ Hand | ChangeType$ Creature.Colorless | ChangeTypeDesc$ colorless creature | ChangeNum$ 1 | SpellDescription$ Search your library for a colorless creature card, reveal it, put it into your hand, then shuffle. +SVar:AILandRemovalMinScore:170 AI:RemoveDeck:Random DeckNeeds:Color$Colorless DeckHints:Type$Eldrazi diff --git a/forge-gui/res/cardsfolder/f/field_of_the_dead.txt b/forge-gui/res/cardsfolder/f/field_of_the_dead.txt index 4ad887cd652..9fd53aace38 100644 --- a/forge-gui/res/cardsfolder/f/field_of_the_dead.txt +++ b/forge-gui/res/cardsfolder/f/field_of_the_dead.txt @@ -7,5 +7,6 @@ A:AB$ Mana | Cost$ T | Produced$ C | SpellDescription$ Add {C}. T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self,Land.Other+YouCtrl | CheckSVar$ X | SVarCompare$ GE7 | Execute$ TrigToken | TriggerDescription$ Whenever CARDNAME or another land you control enters, if you control seven or more lands with different names, create a 2/2 black Zombie creature token. SVar:TrigToken:DB$ Token | TokenScript$ b_2_2_zombie | TokenOwner$ You SVar:X:Count$Valid Land.YouCtrl$DifferentCardNames +SVar:AILandRemovalMinScore:170 DeckHas:Ability$Token Oracle:Field of the Dead enters tapped.\n{T}: Add {C}.\nWhenever Field of the Dead or another land you control enters, if you control seven or more lands with different names, create a 2/2 black Zombie creature token. diff --git a/forge-gui/res/cardsfolder/g/glacial_chasm.txt b/forge-gui/res/cardsfolder/g/glacial_chasm.txt index 2b8049447ec..781feddd045 100644 --- a/forge-gui/res/cardsfolder/g/glacial_chasm.txt +++ b/forge-gui/res/cardsfolder/g/glacial_chasm.txt @@ -8,4 +8,5 @@ S:Mode$ CantAttack | ValidCard$ Creature.YouCtrl | Description$ Creatures you co R:Event$ DamageDone | ActiveZones$ Battlefield | Prevent$ True | ValidTarget$ You | Description$ Prevent all damage that would be dealt to you. AI:RemoveDeck:All SVar:NonStackingEffect:True +SVar:AILandRemovalMinScore:170 Oracle:Cumulative upkeep—Pay 2 life. (At the beginning of your upkeep, put an age counter on this permanent, then sacrifice it unless you pay its upkeep cost for each age counter on it.)\nWhen Glacial Chasm enters, sacrifice a land.\nCreatures you control can't attack.\nPrevent all damage that would be dealt to you. diff --git a/forge-gui/res/cardsfolder/m/maze_of_ith.txt b/forge-gui/res/cardsfolder/m/maze_of_ith.txt index 88507754ef8..99ed3294c1e 100644 --- a/forge-gui/res/cardsfolder/m/maze_of_ith.txt +++ b/forge-gui/res/cardsfolder/m/maze_of_ith.txt @@ -6,4 +6,5 @@ SVar:DBPump:DB$ Effect | ReplacementEffects$ RPrevent1,RPrevent2 | RememberObjec SVar:RPrevent1:Event$ DamageDone | Prevent$ True | IsCombat$ True | ValidSource$ Card.IsRemembered | Description$ Prevent all combat damage that would be dealt to and dealt by that creature this turn. SVar:RPrevent2:Event$ DamageDone | Prevent$ True | IsCombat$ True | ValidTarget$ Card.IsRemembered | Description$ Prevent all combat damage that would be dealt to and dealt by that creature this turn. | Secondary$ True AI:RemoveDeck:Random +SVar:AILandRemovalMinScore:170 Oracle:{T}: Untap target attacking creature. Prevent all combat damage that would be dealt to and dealt by that creature this turn. diff --git a/forge-gui/res/cardsfolder/n/nykthos_shrine_to_nyx.txt b/forge-gui/res/cardsfolder/n/nykthos_shrine_to_nyx.txt index 2570a956a23..6fe477a3e58 100644 --- a/forge-gui/res/cardsfolder/n/nykthos_shrine_to_nyx.txt +++ b/forge-gui/res/cardsfolder/n/nykthos_shrine_to_nyx.txt @@ -6,4 +6,5 @@ A:AB$ ChooseColor | Cost$ 2 T | SubAbility$ DBMana | AILogic$ MostProminentCompu SVar:DBMana:DB$ Mana | Produced$ Chosen | Amount$ X | SubAbility$ DBCleanup SVar:DBCleanup:DB$ Cleanup | ClearChosenColor$ True SVar:X:Count$Devotion.Chosen +SVar:AILandRemovalMinScore:170 Oracle:{T}: Add {C}.\n{2}, {T}: Choose a color. Add an amount of mana of that color equal to your devotion to that color. (Your devotion to a color is the number of mana symbols of that color in the mana costs of permanents you control.) diff --git a/forge-gui/res/cardsfolder/t/the_tabernacle_at_pendrell_vale.txt b/forge-gui/res/cardsfolder/t/the_tabernacle_at_pendrell_vale.txt index 5bc2520d9bf..9bb07d59afe 100644 --- a/forge-gui/res/cardsfolder/t/the_tabernacle_at_pendrell_vale.txt +++ b/forge-gui/res/cardsfolder/t/the_tabernacle_at_pendrell_vale.txt @@ -7,6 +7,7 @@ SVar:TabernacleDestroy:DB$ Destroy | Defined$ Self | UnlessPayer$ You | UnlessCo SVar:NeedsToPlayVar:CountOpps GTCountMe SVar:CountOpps:Count$Valid Creature.OppCtrl SVar:CountMe:Count$Valid Creature.YouCtrl +SVar:AILandRemovalMinScore:170 AI:RemoveDeck:Random DeckHints:Type$Enchantment|Planeswalker|Artifact|Instant|Sorcery Oracle:All creatures have "At the beginning of your upkeep, destroy this creature unless you pay {1}." diff --git a/forge-gui/res/cardsfolder/t/three_tree_city.txt b/forge-gui/res/cardsfolder/t/three_tree_city.txt index f73a587c479..1a0127f861b 100644 --- a/forge-gui/res/cardsfolder/t/three_tree_city.txt +++ b/forge-gui/res/cardsfolder/t/three_tree_city.txt @@ -6,4 +6,5 @@ SVar:ChooseCT:DB$ ChooseType | Defined$ You | Type$ Creature | SpellDescription$ A:AB$ Mana | Cost$ T | Produced$ C | SpellDescription$ Add {C}. A:AB$ Mana | Cost$ 2 T | Produced$ Any | Amount$ X | SpellDescription$ Choose a color. Add an amount of mana of that color equal to the number of creatures you control of the chosen type. SVar:X:Count$Valid Creature.ChosenType+YouCtrl +SVar:AILandRemovalMinScore:170 Oracle:As Three Tree City enters, choose a creature type.\n{T}: Add {C}.\n{2}, {T}: Choose a color. Add an amount of mana of that color equal to the number of creatures you control of the chosen type. diff --git a/forge-gui/res/cardsfolder/u/urzas_saga.txt b/forge-gui/res/cardsfolder/u/urzas_saga.txt index 1762f9adaa2..9a2e840af04 100644 --- a/forge-gui/res/cardsfolder/u/urzas_saga.txt +++ b/forge-gui/res/cardsfolder/u/urzas_saga.txt @@ -7,6 +7,7 @@ SVar:ABMana:AB$ Mana | Cost$ T | Produced$ C | SpellDescription$ Add {C}. SVar:Animate2:DB$ Animate | Defined$ Self | Abilities$ ABToken | Duration$ Permanent | SpellDescription$ CARDNAME gains "{2}, {T}: Create a 0/0 colorless Construct artifact creature token with 'This creature gets +1/+1 for each artifact you control.'" SVar:ABToken:AB$ Token | Cost$ 2 T | TokenScript$ c_0_0_a_construct_total_artifacts | SpellDescription$ Create a 0/0 colorless Construct artifact creature token with "This creature gets +1/+1 for each artifact you control." SVar:Tutor:DB$ ChangeZone | Origin$ Library | Destination$ Battlefield | ChangeType$ Artifact.ManaCost0,Artifact.ManaCost1 | ChangeNum$ 1 | SpellDescription$ Search your library for an artifact card with mana cost {0} or {1}, put it onto the battlefield, then shuffle. +SVar:AILandRemovalMinScore:170 DeckHas:Ability$Token DeckNeeds:Type$Artifact Oracle:(As this Saga enters and after your draw step, add a lore counter. Sacrifice after III.)\nI — Urza's Saga gains "{T}: Add {C}."\nII — Urza's Saga gains "{2}, {T}: Create a 0/0 colorless Construct artifact creature token with 'This creature gets +1/+1 for each artifact you control.'"\nIII — Search your library for an artifact card with mana cost {0} or {1}, put it onto the battlefield, then shuffle. diff --git a/forge-gui/res/cardsfolder/v/valakut_the_molten_pinnacle.txt b/forge-gui/res/cardsfolder/v/valakut_the_molten_pinnacle.txt index e97d901375a..94ad0072d1a 100644 --- a/forge-gui/res/cardsfolder/v/valakut_the_molten_pinnacle.txt +++ b/forge-gui/res/cardsfolder/v/valakut_the_molten_pinnacle.txt @@ -6,4 +6,5 @@ SVar:ETBTapped:DB$ Tap | Defined$ Self | ETB$ True T:Mode$ ChangesZone | ValidCard$ Mountain.YouCtrl | Origin$ Any | Destination$ Battlefield | Execute$ TrigDamage | IsPresent$ Mountain.YouCtrl | PresentCompare$ GE6 | NoResolvingCheck$ True | TriggerZones$ Battlefield | TriggerDescription$ Whenever a Mountain you control enters, if you control at least five other Mountains, you may have CARDNAME deal 3 damage to any target. SVar:TrigDamage:DB$ DealDamage | ValidTgts$ Any | NumDmg$ 3 | ConditionPresent$ Mountain.YouCtrl+!TriggeredCard | ConditionCompare$ GE5 | OptionalDecider$ You A:AB$ Mana | Cost$ T | Produced$ R | SpellDescription$ Add {R}. +SVar:AILandRemovalMinScore:170 Oracle:Valakut, the Molten Pinnacle enters tapped.\nWhenever a Mountain you control enters, if you control at least five other Mountains, you may have Valakut, the Molten Pinnacle deal 3 damage to any target.\n{T}: Add {R}. From 10f679770afaef85ab696bbc1bfab2379da537df Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Mon, 27 Apr 2026 12:55:57 -0700 Subject: [PATCH 8/8] Use isSacrificeSelfCost and findSubAbilityByType --- .../src/main/java/forge/ai/ComputerUtilCard.java | 13 +------------ .../src/main/java/forge/ai/ability/DestroyAi.java | 3 +-- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index c6f770bb5ff..5c558e8d3ce 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -342,7 +342,7 @@ private static int evaluateLandRemovalPriority(final Player ai, final Card land, // Grounds or Blast Zone are relevant, but usually not urgent. score += 40; } - if (ability.getApi() == ApiType.Mana || hasManaSubAbility(ability)) { + if (ability.getApi() == ApiType.Mana || ability.findSubAbilityByType(ApiType.Mana) != null) { // High priority: non-mana root abilities that create mana, // such as Nykthos-style choose-color abilities implemented in // a sub-DB. LandEvaluator sees these as utility, not big mana. @@ -436,17 +436,6 @@ private static boolean aiControlsStolenCreature(final Player ai) { return false; } - private static boolean hasManaSubAbility(final SpellAbility ability) { - SpellAbility sub = ability.getSubAbility(); - while (sub != null) { - if (sub.getApi() == ApiType.Mana) { - return true; - } - sub = sub.getSubAbility(); - } - return false; - } - private static boolean isLandAnimationAbility(final SpellAbility ability) { if (ability.getApi() == ApiType.Animate) { return true; diff --git a/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java b/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java index 9c2bcc14b14..146db23a4d0 100644 --- a/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java @@ -443,8 +443,7 @@ public boolean doLandForLandRemovalLogic(SpellAbility sa, Player ai, Card tgtLan // Tectonic Edge, Strip Mine, and Wasteland should not cash in a large // share of the AI's own mana base for a merely medium utility target. boolean sacrificesSourceLand = sa.getHostCard().isLand() - && sa.getPayCosts() != null - && sa.getPayCosts().hasSpecificCostType(CostSacrifice.class); + && ComputerUtilCost.isSacrificeSelfCost(sa.getPayCosts()); if (sacrificesSourceLand && !highPriorityTgt && !canManaLock && !canColorLock && numLandsOTB <= 3) { return false; }