From b9dd52760f6a6fc387aba6f5e90c59a49d4740f1 Mon Sep 17 00:00:00 2001 From: Michael Kamensky Date: Sat, 13 Nov 2021 19:31:59 +0300 Subject: [PATCH] - Basic AI logic support for the Cemetery cycle (VOW) --- .../java/forge/ai/ability/ChangeZoneAi.java | 155 +++++++++++++++--- .../upcoming/cemetery_desecrator.txt | 2 +- .../upcoming/cemetery_gatekeeper.txt | 3 +- .../upcoming/cemetery_illuminator.txt | 2 +- .../upcoming/cemetery_protector.txt | 2 +- .../cardsfolder/upcoming/cemetery_prowler.txt | 2 +- 6 files changed, 134 insertions(+), 32 deletions(-) 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 b99f6d8598f..f3e8c316ba7 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1,32 +1,12 @@ package forge.ai.ability; -import java.util.*; - -import forge.game.card.*; -import forge.game.keyword.Keyword; -import org.apache.commons.lang3.StringUtils; - import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; - -import forge.ai.AiAttackController; -import forge.ai.AiCardMemory; -import forge.ai.AiController; -import forge.ai.AiProps; -import forge.ai.ComputerUtil; -import forge.ai.ComputerUtilAbility; -import forge.ai.ComputerUtilCard; -import forge.ai.ComputerUtilCombat; -import forge.ai.ComputerUtilCost; -import forge.ai.ComputerUtilMana; -import forge.ai.PlayerControllerAi; -import forge.ai.SpecialAiLogic; -import forge.ai.SpecialCardAi; -import forge.ai.SpellAbilityAi; -import forge.ai.SpellApiToAi; +import forge.ai.*; +import forge.card.CardType; import forge.card.MagicColor; import forge.game.Game; import forge.game.GameEntity; @@ -35,12 +15,14 @@ import forge.game.GlobalRuleChange; import forge.game.ability.AbilityKey; import forge.game.ability.AbilityUtils; import forge.game.ability.ApiType; +import forge.game.card.*; import forge.game.card.CardPredicates.Presets; import forge.game.combat.Combat; import forge.game.cost.Cost; import forge.game.cost.CostDiscard; import forge.game.cost.CostPart; import forge.game.cost.CostPutCounter; +import forge.game.keyword.Keyword; import forge.game.phase.PhaseHandler; import forge.game.phase.PhaseType; import forge.game.player.Player; @@ -50,8 +32,12 @@ import forge.game.spellability.SpellAbility; import forge.game.spellability.TargetRestrictions; import forge.game.staticability.StaticAbilityMustTarget; import forge.game.zone.ZoneType; +import forge.util.Aggregates; import forge.util.MyRandom; import forge.util.collect.FCollection; +import org.apache.commons.lang3.StringUtils; + +import java.util.*; public class ChangeZoneAi extends SpellAbilityAi { /* @@ -1540,11 +1526,9 @@ public class ChangeZoneAi extends SpellAbilityAi { if ("DeathgorgeScavenger".equals(logic)) { return SpecialCardAi.DeathgorgeScavenger.consider(ai, sa); - } - if ("ExtraplanarLens".equals(logic)) { + } else if ("ExtraplanarLens".equals(logic)) { return SpecialCardAi.ExtraplanarLens.consider(ai, sa); - } - if ("ExileCombatThreat".equals(logic)) { + } else if ("ExileCombatThreat".equals(logic)) { return doExileCombatThreatLogic(ai, sa); } @@ -1570,7 +1554,7 @@ public class ChangeZoneAi extends SpellAbilityAi { return null; } if (sa.hasParam("AILogic")) { - String logic = sa.getParam("AILogic"); + String logic = sa.getParamOrDefault("AILogic", ""); if ("NeverBounceItself".equals(logic)) { Card source = sa.getHostCard(); if (fetchList.contains(source) && (fetchList.size() > 1 || !sa.getRootAbility().isMandatory())) { @@ -1593,6 +1577,8 @@ public class ChangeZoneAi extends SpellAbilityAi { multipleCardsToChoose.remove(0); return choice; } + } else if (logic.startsWith("ExilePreference")) { + return doExilePreferenceLogic(decider, sa, fetchList); } } if (fetchList.isEmpty()) { @@ -2029,6 +2015,121 @@ public class ChangeZoneAi extends SpellAbilityAi { return false; } + public static Card doExilePreferenceLogic(final Player aiPlayer, final SpellAbility sa, CardCollection fetchList) { + if (fetchList.isEmpty()) { + return null; // there was nothing to choose at all + } + + final Card host = sa.getHostCard(); + final String logic = sa.getParamOrDefault("AILogic", ""); + final String valid = logic.split(":")[1]; + final boolean isCurse = logic.contains("Curse"); + final boolean isOwnOnly = logic.contains("OwnOnly"); + final boolean isWorstChoice = logic.contains("Worst"); + final boolean isRandomChoice = logic.contains("Random"); + + if (logic.endsWith("HighestCMC")) { + return ComputerUtilCard.getMostExpensivePermanentAI(fetchList); + } else if (logic.contains("MostProminent")) { + CardCollection scanList = new CardCollection(); + if (logic.endsWith("OwnType")) { + scanList.addAll(aiPlayer.getCardsIn(ZoneType.Library)); + scanList.addAll(aiPlayer.getCardsIn(ZoneType.Hand)); + } else if (logic.endsWith("OppType")) { + // this assumes that the deck list is known to the AI before the match starts, + // so it's possible to figure out what remains in library/hand if you know what's + // in graveyard, exile, etc. + scanList.addAll(aiPlayer.getOpponents().getCardsIn(ZoneType.Library)); + scanList.addAll(aiPlayer.getOpponents().getCardsIn(ZoneType.Hand)); + } + + if (logic.contains("NonLand")) { + scanList = CardLists.filter(scanList, Predicates.not(Presets.LANDS)); + } + + if (logic.contains("NonExiled")) { + scanList = CardLists.filter(scanList, new Predicate() { + @Override + public boolean apply(Card card) { + CardCollectionView imprinted = host.getImprintedCards(); + if (imprinted.isEmpty()) { + return true; + } + for (Card c : imprinted) { + return !c.getType().sharesCardTypeWith(card.getType()); + } + return true; + } + }); + } + + final Map typesInDeck = Maps.newHashMap(); + for (final Card c : scanList) { + for (CardType.CoreType ct : c.getType().getCoreTypes()) { + Integer count = typesInDeck.get(ct); + if (count == null) { + count = 0; + } + typesInDeck.put(ct, count + 1); + } + } + int max = 0; + CardType.CoreType maxType = CardType.CoreType.Land; + + for (final Map.Entry entry : typesInDeck.entrySet()) { + final CardType.CoreType type = entry.getKey(); + + if (max < entry.getValue()) { + max = entry.getValue(); + maxType = type; + } + } + + final CardType.CoreType determinedMaxType = maxType; + CardCollection preferredList = CardLists.filter(fetchList, new Predicate() { + @Override + public boolean apply(Card card) { + return card.getType().hasType(determinedMaxType); + } + }); + + return preferredList.isEmpty() ? Aggregates.random(fetchList) : Aggregates.random(preferredList); + } + + // Filter by preference. If nothing is preferred, choose the best/worst/random target for the opponent + // or for the AI depending on the settings. This logic must choose at least something if at all possible, + // since it's called from chooseSingleCard. + CardCollection preferredList = CardLists.filter(fetchList, new Predicate() { + @Override + public boolean apply(Card card) { + boolean playerPref = true; + if (isCurse) { + playerPref = card.getController().isOpponentOf(aiPlayer); + } else if (isOwnOnly) { + playerPref = card.getController().equals(aiPlayer) || !card.getController().isOpponentOf(aiPlayer); + } + + if (!playerPref) { + return false; + } + + return card.isValid(valid, aiPlayer, host, sa); // for things like ExilePreference:Land.Basic + } + }); + + if (!preferredList.isEmpty()) { + if (isRandomChoice) { + return Aggregates.random(preferredList); + } + return isWorstChoice ? ComputerUtilCard.getWorstAI(preferredList) : ComputerUtilCard.getBestAI(preferredList); + } else { + if (isRandomChoice) { + return Aggregates.random(preferredList); + } + return isWorstChoice ? ComputerUtilCard.getWorstAI(fetchList) : ComputerUtilCard.getBestAI(fetchList); + } + } + private static CardCollection getSafeTargetsIfUnlessCostPaid(Player ai, SpellAbility sa, Iterable potentialTgts) { // Determines if the controller of each potential target can negate the ChangeZone effect // by paying the Unless cost. Returns the list of targets that can be saved that way. diff --git a/forge-gui/res/cardsfolder/upcoming/cemetery_desecrator.txt b/forge-gui/res/cardsfolder/upcoming/cemetery_desecrator.txt index cda76ff938e..33e9ffaa5a9 100644 --- a/forge-gui/res/cardsfolder/upcoming/cemetery_desecrator.txt +++ b/forge-gui/res/cardsfolder/upcoming/cemetery_desecrator.txt @@ -4,7 +4,7 @@ Types:Creature Zombie PT:4/4 T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigExile | TriggerDescription$ When CARDNAME enters the battlefield or dies, exile another card from a graveyard. T:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self | Execute$ TrigExile | Secondary$ True | TriggerDescription$ When CARDNAME enters the battlefield or dies, exile another card from a graveyard. -SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | Hidden$ True | RememberChanged$ True | ChangeType$ Card.Other | ChangeNum$ 1 | Mandatory$ True | SubAbility$ DBImmediateTrigger +SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | Hidden$ True | RememberChanged$ True | ChangeType$ Card.Other | ChangeNum$ 1 | Mandatory$ True | AILogic$ ExilePreference:HighestCMC | SubAbility$ DBImmediateTrigger SVar:DBImmediateTrigger:DB$ ImmediateTrigger | ConditionDefined$ Remembered | ConditionPresent$ Card | Execute$ TrigCharm | TriggerDescription$ When you do, ABILITY SVar:TrigCharm:DB$ Charm | Choices$ DBRemoveCounter,DBPump SVar:DBRemoveCounter:DB$ RemoveCounter | ValidTgts$ Permanent | TgtPrompt$ Select target permanent | CounterType$ Any | CounterNum$ X | SubAbility$ DBCleanup | SpellDescription$ Remove X counters from target permanent, where X is the mana value of the exiled card. diff --git a/forge-gui/res/cardsfolder/upcoming/cemetery_gatekeeper.txt b/forge-gui/res/cardsfolder/upcoming/cemetery_gatekeeper.txt index c2b2a601824..6b666851a42 100644 --- a/forge-gui/res/cardsfolder/upcoming/cemetery_gatekeeper.txt +++ b/forge-gui/res/cardsfolder/upcoming/cemetery_gatekeeper.txt @@ -4,8 +4,9 @@ Types:Creature Vampire PT:2/1 K:First strike T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigExile | TriggerDescription$ When CARDNAME enters the battlefield, exile a card from a graveyard. -SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True +SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True | AILogic$ ExilePreference:MostProminentOppType T:Mode$ LandPlayed | ValidCard$ Land.sharesCardTypeWith Imprinted.ExiledWithSource | TriggerZones$ Battlefield | Execute$ TrigDamage | TriggerDescription$ Whenever a player plays a land or casts a spell, if it shares a card type with the exiled card, Cemetery Gatekeeper deals 2 damage to that player. T:Mode$ SpellCast | ValidCard$ Card.sharesCardTypeWith Imprinted.ExiledWithSource | ValidActivatingPlayer$ Player | TriggerZones$ Battlefield | Execute$ TrigDamage | Secondary$ True | TriggerDescription$ Whenever a player plays a land or casts a spell, if it shares a card type with the exiled card, Cemetery Gatekeeper deals 2 damage to that player. SVar:TrigDamage:DB$ DealDamage | NumDmg$ 2 | Defined$ TriggeredCardController +SVar:AICastPreference:NeverCastIfLifeBelow$ 8 Oracle:First strike\nWhen Cemetery Gatekeeper enters the battlefield, exile a card from a graveyard.\nWhenever a player plays a land or casts a spell, if it shares a card type with the exiled card, Cemetery Gatekeeper deals 2 damage to that player. diff --git a/forge-gui/res/cardsfolder/upcoming/cemetery_illuminator.txt b/forge-gui/res/cardsfolder/upcoming/cemetery_illuminator.txt index 51d43f5e1d1..bdda273ec21 100644 --- a/forge-gui/res/cardsfolder/upcoming/cemetery_illuminator.txt +++ b/forge-gui/res/cardsfolder/upcoming/cemetery_illuminator.txt @@ -5,7 +5,7 @@ PT:2/3 K:Flying T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigExile | TriggerDescription$ When CARDNAME enters the battlefield or attacks, exile a card from a graveyard. T:Mode$ Attacks | ValidCard$ Card.Self | Execute$ TrigExile | Secondary$ True | TriggerDescription$ Whenever CARDNAME enters the battlefield or attacks, exile a card from a graveyard. -SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True +SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True | AILogic$ ExilePreference:MostProminentOwnType S:Mode$ Continuous | Affected$ Card.TopLibrary+YouCtrl | AffectedZone$ Library | MayLookAt$ You | Description$ You may look at the top card of your library any time. S:Mode$ Continuous | EffectZone$ Battlefield | Affected$ Card.nonLand+TopLibrary+YouCtrl+sharesCardTypeWith Imprinted.ExiledWithSource | AffectedZone$ Library | MayPlay$ True | MayPlayLimit$ 1 | Description$ Once each turn, you may cast a spell from the top of your library if it shares a card type with a card exiled with CARDNAME. DeckHas:Ability$Graveyard diff --git a/forge-gui/res/cardsfolder/upcoming/cemetery_protector.txt b/forge-gui/res/cardsfolder/upcoming/cemetery_protector.txt index 913eea7fe6e..a48f1e2068d 100644 --- a/forge-gui/res/cardsfolder/upcoming/cemetery_protector.txt +++ b/forge-gui/res/cardsfolder/upcoming/cemetery_protector.txt @@ -4,7 +4,7 @@ Types:Creature Human Soldier PT:3/4 K:Flash T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigExile | TriggerDescription$ When CARDNAME enters the battlefield, exile a card from a graveyard. -SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True +SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True | AILogic$ ExilePreference:Land T:Mode$ LandPlayed | ValidCard$ Land.YouOwn+sharesCardTypeWith Imprinted.ExiledWithSource | Execute$ TrigToken | TriggerZones$ Battlefield | TriggerDescription$ Whenever you play a land or cast a spell, if it shares a card type with the exiled card, create a 1/1 white Human creature token. T:Mode$ SpellCast | ValidCard$ Card.sharesCardTypeWith Imprinted.ExiledWithSource | ValidActivatingPlayer$ You | Execute$ TrigToken | TriggerZones$ Battlefield | Secondary$ True | TriggerDescription$ Whenever you play a land or cast a spell, if it shares a card type with the exiled card, create a 1/1 white Human creature token. SVar:TrigToken:DB$ Token | TokenScript$ w_1_1_human diff --git a/forge-gui/res/cardsfolder/upcoming/cemetery_prowler.txt b/forge-gui/res/cardsfolder/upcoming/cemetery_prowler.txt index ec20d5b23e1..6b6cf854896 100644 --- a/forge-gui/res/cardsfolder/upcoming/cemetery_prowler.txt +++ b/forge-gui/res/cardsfolder/upcoming/cemetery_prowler.txt @@ -5,7 +5,7 @@ PT:3/4 K:Vigilance T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigExile | TriggerDescription$ When CARDNAME enters the battlefield or attacks, exile a card from a graveyard. T:Mode$ Attacks | ValidCard$ Card.Self | Execute$ TrigExile | Secondary$ True | TriggerDescription$ Whenever CARDNAME enters the battlefield or attacks, exile a card from a graveyard. -SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True +SVar:TrigExile:DB$ ChangeZone | Origin$ Graveyard | Destination$ Exile | ChangeType$ Card | ChangeNum$ 1 | SelectPrompt$ Select a card from a graveyard | Mandatory$ True | Hidden$ True | Imprint$ True | AILogic$ ExilePreference:MostProminentNonLandNonExiledOwnType S:Mode$ ReduceCost | ValidCard$ Card | Type$ Spell | Amount$ AffectedX | Activator$ You | Description$ Spells you cast cost {1} less to cast for each card type they share with cards exiled with CARDNAME. SVar:AffectedX:Count$TypesSharedWith Imprinted.ExiledWithSource T:Mode$ ChangesZone | Origin$ Battlefield | ValidCard$ Card.Self | Destination$ Any | Execute$ DBCleanup | Static$ True