From a9e95a3b00de1516c261800ea74fa324fe1e901d Mon Sep 17 00:00:00 2001 From: Chris H Date: Sat, 9 May 2026 19:09:47 -0400 Subject: [PATCH 1/2] Expand key card tutors for more tutor types --- .../java/forge/ai/ability/ChangeZoneAi.java | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 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 b30b5dc0880..ee66e4a0e72 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1463,6 +1463,16 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } } + // Can we extract this up to SPellAbilityAi for everything to have access to? + Card keycardFound = null; + for(String keyName : keyCards) { + CardCollection withKeyCard = CardLists.filter(fetchList, CardPredicates.nameEquals(keyName)); + if (withKeyCard.isEmpty()) { + continue; + } + keycardFound = withKeyCard.getFirst(); + } + if (sa.hasParam("AILogic")) { String logic = sa.getParamOrDefault("AILogic", ""); if ("NeverBounceItself".equals(logic)) { @@ -1474,6 +1484,8 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } else if ("WorstCard".equals(logic)) { return ComputerUtilCard.getWorstAI(fetchList); } else if ("BestCard".equals(logic)) { + if (keycardFound != null) return keycardFound; + return ComputerUtilCard.getBestAI(fetchList); // generally also means the most expensive one or close to it } else if ("Mairsil".equals(logic)) { return SpecialCardAi.MairsilThePretender.considerCardFromList(fetchList); @@ -1504,6 +1516,7 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List CardLists.shuffle(fetchList); // Save a card as a default, in case we can't find anything suitable. Card first = fetchList.get(0); + if (ZoneType.Battlefield.equals(destination)) { fetchList = CardLists.filter(fetchList, c1 -> { if (c1.getType().isLegendary()) { @@ -1539,13 +1552,19 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } } } else if (origin.contains(ZoneType.Library) && (type.contains("Basic") || areAllBasics(type))) { + if (keycardFound != null) return keycardFound; + c = basicManaFixing(decider, fetchList); } else if (ZoneType.Hand.equals(destination) && CardLists.getNotType(fetchList, "Creature").isEmpty()) { + if (keycardFound != null) return keycardFound; + c = chooseCreature(decider, fetchList); } else if (ZoneType.Battlefield.equals(destination) || ZoneType.Graveyard.equals(destination)) { if (!activator.equals(decider) && sa.hasParam("GainControl")) { c = ComputerUtilCard.getWorstAI(fetchList); } else { + if (keycardFound != null) return keycardFound; + c = ComputerUtilCard.getBestAI(fetchList); } } else { @@ -1556,13 +1575,7 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } // Tutor for the first key card in the list, since the list should be in priority order - for(String keyName : keyCards) { - CardCollection withKeyCard = CardLists.filter(fetchList, CardPredicates.nameEquals(keyName)); - if (withKeyCard.isEmpty()) { - continue; - } - return withKeyCard.getFirst(); - } + if (keycardFound != null) return keycardFound; // Does AI need a land? // The logic here seems wrong if the decider isn't the same as the player From f881cab4b32fe1373d99f0f72717b3ce8f69261c Mon Sep 17 00:00:00 2001 From: Chris H Date: Sat, 9 May 2026 22:31:32 -0400 Subject: [PATCH 2/2] Allow treefolk harbinger to go fetch lands if they need them --- .../java/forge/ai/ability/ChangeZoneAi.java | 53 +++++++++++++++++++ .../res/cardsfolder/t/treefolk_harbinger.txt | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java index ee66e4a0e72..1a2b9346238 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -1503,6 +1503,12 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List return doExilePreferenceLogic(decider, sa, fetchList); } else if (logic.equals("BounceOwnTrigger")) { return doBounceOwnTriggerLogic(decider, sa, fetchList); + } else if (logic.equals("ConsiderRamp")) { + Card c = considerRamp(decider, sa, fetchList, keycardFound); + + if (c != null) { + return c; + } } } if (fetchList.isEmpty()) { @@ -2051,6 +2057,53 @@ private static Card doBounceOwnTriggerLogic(Player ai, SpellAbility sa, CardColl return null; } + private static Card considerRamp(Player ai, SpellAbility sa, CardCollection choices, Card keycardFound) { + // For cards that might fetch a land or other things, but really might need the land right now. + // Do a rough check of available mana sources (lands on battlefield, other mana producers on battlefield, + // and lands in hand) to decide whether to prioritize fetching a land. + + // Count lands already on the battlefield + int landsOnBattlefield = ai.getLandsInPlay().size(); + + // Count non-land permanents on the battlefield that produce mana (e.g. mana rocks, dorks) + int otherManaProducers = 0; + for (Card c : ai.getCardsIn(ZoneType.Battlefield)) { + if (!c.getManaAbilities().isEmpty()) { + otherManaProducers++; + } + } + + // Count lands in hand (they represent future mana sources we expect to play) + int landsInHand = CardLists.filter(ai.getCardsIn(ZoneType.Hand), CardPredicates.LANDS).size(); + + int totalManaSources = landsOnBattlefield + otherManaProducers + landsInHand; + + // Base threshold: below this many total mana sources we should prioritize getting a land + int threshold = 4; + + // If we have a keycard target, also make sure we'll eventually have enough mana to cast it. + // If the keycard's CMC is further than one land drop away from our current sources, keep ramping. + if (keycardFound != null && keycardFound.getCMC() > totalManaSources + 1) { + threshold = Math.max(threshold, keycardFound.getCMC() - 1); + } + + // If we are below the threshold, look for a land in the available choices and prefer it + if (totalManaSources < threshold) { + CardCollection landsInChoices = CardLists.filter(choices, CardPredicates.LANDS); + if (!landsInChoices.isEmpty()) { + // Prefer a land that produces mana over a purely tapped/utility land + CardCollection manaLands = CardLists.filter(landsInChoices, CardPredicates.LANDS_PRODUCING_MANA); + if (!manaLands.isEmpty()) { + return manaLands.get(0); + } + return landsInChoices.get(0); + } + } + + // We have enough mana sources — let the caller use keycardFound (may be null, triggering fallthrough) + return keycardFound; + } + @Override public boolean willPayUnlessCost(Player payer, SpellAbility sa, Cost cost, boolean alreadyPaid, FCollectionView payers) { final Card host = sa.getHostCard(); diff --git a/forge-gui/res/cardsfolder/t/treefolk_harbinger.txt b/forge-gui/res/cardsfolder/t/treefolk_harbinger.txt index 37aa46d4251..d0c1017c775 100644 --- a/forge-gui/res/cardsfolder/t/treefolk_harbinger.txt +++ b/forge-gui/res/cardsfolder/t/treefolk_harbinger.txt @@ -2,7 +2,7 @@ Name:Treefolk Harbinger ManaCost:G Types:Creature Treefolk Druid PT:0/3 -T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigChange | OptionalDecider$ You | TriggerDescription$ When CARDNAME enters, you may search your library for a Treefolk or Forest card, reveal it, then shuffle and put that card on top. +T:Mode$ ChangesZone | AILogic$ ConsiderRamp | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigChange | OptionalDecider$ You | TriggerDescription$ When CARDNAME enters, you may search your library for a Treefolk or Forest card, reveal it, then shuffle and put that card on top. SVar:TrigChange:DB$ ChangeZone | Origin$ Library | Destination$ Library | LibraryPosition$ 0 | ChangeType$ Card.Treefolk,Card.Forest | ChangeNum$ 1 | ShuffleNonMandatory$ True AI:RemoveDeck:Random Oracle:When Treefolk Harbinger enters, you may search your library for a Treefolk or Forest card, reveal it, then shuffle and put that card on top.