Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 206 additions & 6 deletions forge-ai/src/main/java/forge/ai/ComputerUtilCard.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,24 +22,36 @@
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;
import forge.game.Game;
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;
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;
Expand All @@ -51,9 +65,15 @@
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;
import forge.util.Aggregates;
import forge.util.Expressions;
import forge.util.IterableUtil;
import forge.util.MyRandom;
import forge.util.TextUtil;

public class ComputerUtilCard {
public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) {
Expand Down Expand Up @@ -258,6 +278,186 @@ public static Card getBestLandAI(final Iterable<Card> list) {
.orElseGet(() -> Aggregates.random(bLand)); // random tapped land of least represented type
}

public static Card getBestLandToRemoveAI(final Player ai, final Iterable<Card> list, final SpellAbility removal) {
Comment thread
Madwand99 marked this conversation as resolved.
final List<Card> lands = CardLists.filter(list, CardPredicates.LANDS);
if (lands.isEmpty()) {
return null;
}

return lands.stream()
.max(Comparator.comparingInt(c -> evaluateLandRemovalPriority(ai, c, removal)))
.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;
}

// 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);

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 += 100;
} else {
score = Math.max(0, score - 50);
}
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 += 40;
}
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.
score += 100;
}
}

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 (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()) {
// 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;
}

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 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;
}

/**
* <p>
* getWorstLand.
Expand Down
26 changes: 18 additions & 8 deletions forge-ai/src/main/java/forge/ai/ability/DestroyAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -425,23 +425,33 @@ 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()
&& ComputerUtilCost.isSacrificeSelfCost(sa.getPayCosts());
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)) {
Expand Down
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/d/dark_depths.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/e/eye_of_ugin.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/f/field_of_the_dead.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/g/glacial_chasm.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/m/maze_of_ith.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/n/nykthos_shrine_to_nyx.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
1 change: 1 addition & 0 deletions forge-gui/res/cardsfolder/t/three_tree_city.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading