From ef380a41ad48415be789602f66418a6569ea8f7d Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Fri, 1 May 2026 12:05:55 -0700 Subject: [PATCH 1/3] Improve AI draw-punisher avoidance heuristics --- .../src/main/java/forge/ai/AiController.java | 8 + .../main/java/forge/ai/ComputerUtilCard.java | 207 ++++++++++++++++++ .../forge/ai/ability/ChangeZoneAllAi.java | 2 +- .../ai/simulation/SpellAbilityPicker.java | 3 + .../SpellAbilityPickerSimulationTest.java | 198 ++++++++++++++++- 5 files changed, 415 insertions(+), 3 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 0e30a395d3b..301c4013efe 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -859,6 +859,10 @@ private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) { return AiPlayDecision.AnotherTime; } + if (ComputerUtilCard.shouldAvoidDrawPunisher(player, sa)) { + return AiPlayDecision.CurseEffects; + } + // this is the "heaviest" check, which also sets up targets, defines X, etc. AiPlayDecision canPlay = canPlaySa(sa); @@ -1274,6 +1278,10 @@ public boolean getBoolProperty(AiProps propName) { } public AiPlayDecision canPlayFromEffectAI(Spell spell, boolean mandatory, boolean withoutPayingManaCost) { + if (!mandatory && ComputerUtilCard.shouldAvoidDrawPunisher(player, spell)) { + return AiPlayDecision.CurseEffects; + } + if (spell instanceof SpellApiBased) { boolean chance; if (withoutPayingManaCost) { diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index a061b90bae0..4106fd4b7c8 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -76,6 +76,8 @@ import forge.util.TextUtil; public class ComputerUtilCard { + private static final int DANGEROUS_DRAW_COUNT = 3; + public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) { CardCollectionView all = list; if (targeted) { @@ -892,6 +894,211 @@ public static boolean canBeBlockedProfitably(final Player ai, Card attacker, boo return ComputerUtilCombat.attackerWouldBeDestroyed(ai, attacker, combat); } + public static boolean shouldAvoidDrawPunisher(final Player ai, final SpellAbility sa) { + if (ai == null || sa == null || sa.isLandAbility()) { + return false; + } + + final Card host = sa.getHostCard(); + if (host == null || isOwnCommanderFromCommandZone(ai, host)) { + return false; + } + + final int estimatedDraws = estimateSelfDrawsFromPlay(ai, sa); + if (estimatedDraws < DANGEROUS_DRAW_COUNT) { + return false; + } + + for (final Player opponent : ai.getOpponents()) { + if (opponentHasDrawPunisher(ai, opponent, estimatedDraws)) { + return true; + } + } + return false; + } + + private static int estimateSelfDrawsFromPlay(final Player ai, final SpellAbility sa) { + int draws = estimateSelfDrawsFromAbility(ai, sa, ai.getCardsIn(ZoneType.Hand).size()); + + if (sa.isSpell()) { + for (final Card card : ai.getCardsIn(ZoneType.Battlefield)) { + draws = Math.max(draws, estimateSelfDrawsFromSpellCastTriggers(ai, card, ai.getCardsIn(ZoneType.Hand).size())); + } + } + + final Card host = sa.getHostCard(); + if (host != null) { + draws = Math.max(draws, estimateSelfDrawsFromCardTriggers(ai, host)); + } + return draws; + } + + private static int estimateSelfDrawsFromCardTriggers(final Player ai, final Card card) { + int draws = 0; + for (final Trigger trigger : card.getTriggers()) { + if (trigger.getMode() == TriggerType.SpellCast && triggerMatchesControllerSpellCast(trigger)) { + draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), ai.getCardsIn(ZoneType.Hand).size())); + } else if (trigger.getMode() == TriggerType.Phase && "Draw".equals(trigger.getParam("Phase")) + && triggerMayAffectPlayer(trigger, ai)) { + draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), ai.getCardsIn(ZoneType.Hand).size())); + } + } + return draws; + } + + private static int estimateSelfDrawsFromSpellCastTriggers(final Player ai, final Card card, final int fallbackDraws) { + int draws = 0; + for (final Trigger trigger : card.getTriggers()) { + if (trigger.getMode() == TriggerType.SpellCast && triggerMatchesControllerSpellCast(trigger)) { + draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), fallbackDraws)); + } + } + return draws; + } + + private static boolean triggerMatchesControllerSpellCast(final Trigger trigger) { + final String activator = trigger.getParamOrDefault("ValidActivatingPlayer", "You"); + return activator.contains("You") && !activator.contains("Opponent"); + } + + private static boolean triggerMayAffectPlayer(final Trigger trigger, final Player player) { + final String validPlayer = trigger.getParamOrDefault("ValidPlayer", "Player"); + if (validPlayer.contains("Opponent")) { + return trigger.getHostCard().getController().isOpponentOf(player); + } + if (validPlayer.contains("You")) { + return trigger.getHostCard().getController().equals(player); + } + return validPlayer.contains("Player"); + } + + private static int estimateSelfDrawsFromAbility(final Player ai, final SpellAbility sa, final int fallbackDraws) { + int draws = 0; + boolean movedHand = false; + for (SpellAbility cur = sa; cur != null; cur = cur.getSubAbility()) { + if (cur.getApi() == ApiType.ChangeZoneAll && zoneParamIncludes(cur, "Origin", ZoneType.Hand) + && (zoneParamIncludes(cur, "Destination", ZoneType.Library) + || zoneParamIncludes(cur, "Destination", ZoneType.Graveyard) + || zoneParamIncludes(cur, "Destination", ZoneType.Exile))) { + movedHand = true; + } + + if (cur.getApi() == ApiType.Draw && abilityDrawsForPlayer(cur)) { + draws += estimateDrawAmount(ai, cur, movedHand ? fallbackDraws : 1); + } + } + return draws; + } + + private static boolean zoneParamIncludes(final SpellAbility sa, final String param, final ZoneType zone) { + return sa.hasParam(param) && ZoneType.listValueOf(sa.getParam(param)).contains(zone); + } + + private static boolean abilityDrawsForPlayer(final SpellAbility sa) { + final String defined = sa.getParamOrDefault("Defined", "You"); + if (defined.contains("Player") || defined.contains("TriggeredPlayer") || defined.contains("TargetedAndYou")) { + return true; + } + if (defined.contains("Opponent")) { + return false; + } + return defined.contains("You") || defined.contains("Controller"); + } + + private static int estimateDrawAmount(final Player ai, final SpellAbility sa, final int fallbackDraws) { + final String numCards = sa.getParamOrDefault("NumCards", "1"); + try { + return Integer.parseInt(numCards); + } catch (NumberFormatException ignored) { + if ("X".equals(numCards) && sa.getXManaCostPaid() != null) { + return sa.getXManaCostPaid(); + } + if (numCards.startsWith("Rem") || numCards.contains("Remembered") || "X".equals(numCards)) { + return Math.max(1, fallbackDraws); + } + if (numCards.startsWith("Count$ValidHand")) { + return Math.max(1, ai.getCardsIn(ZoneType.Hand).size()); + } + return 1; + } + } + + private static boolean isOwnCommanderFromCommandZone(final Player ai, final Card host) { + return host.isCommander() && host.isInZone(ZoneType.Command) && host.getOwner().equals(ai); + } + + private static boolean opponentHasDrawPunisher(final Player ai, final Player opponent, final int estimatedDraws) { + for (final Card card : opponent.getCardsIn(ZoneType.Battlefield)) { + if (cardHasDrawPunisher(ai, card, false, estimatedDraws)) { + return true; + } + } + for (final Card card : opponent.getCardsIn(ZoneType.Command)) { + if (cardHasDrawPunisher(ai, card, true, estimatedDraws)) { + return true; + } + } + return false; + } + + private static boolean cardHasDrawPunisher(final Player ai, final Card card, final boolean commandZone, final int estimatedDraws) { + for (final Trigger trigger : card.getTriggers()) { + if (trigger.getMode() != TriggerType.Drawn || !drawTriggerCanAffectPlayer(trigger, ai)) { + continue; + } + if (commandZone && (trigger.getActiveZone() == null || !trigger.getActiveZone().contains(ZoneType.Command))) { + continue; + } + if (!commandZone && !trigger.requirementsCheck(card.getGame())) { + continue; + } + if (drawPunisherImpact(trigger.ensureAbility(), estimatedDraws) >= estimatedDraws) { + return true; + } + } + return false; + } + + private static boolean drawTriggerCanAffectPlayer(final Trigger trigger, final Player ai) { + if (trigger.hasParam("ValidPlayer")) { + return triggerMayAffectPlayer(trigger, ai); + } + + final String validCard = trigger.getParamOrDefault("ValidCard", "Card"); + if (validCard.contains("Opp")) { + return trigger.getHostCard().getController().isOpponentOf(ai); + } + if (validCard.contains("You")) { + return trigger.getHostCard().getController().equals(ai); + } + return validCard.contains("Card"); + } + + private static int drawPunisherImpact(final SpellAbility sa, final int estimatedDraws) { + int impact = 0; + for (SpellAbility cur = sa; cur != null; cur = cur.getSubAbility()) { + if (cur.getApi() == ApiType.DealDamage) { + impact += estimateNumericParam(cur, "NumDmg", 1) * estimatedDraws; + } else if (cur.getApi() == ApiType.LoseLife) { + impact += estimateNumericParam(cur, "LifeAmount", 1) * estimatedDraws; + } else if (cur.getApi() == ApiType.Token) { + impact += estimatedDraws; + } + } + return impact; + } + + private static int estimateNumericParam(final SpellAbility sa, final String param, final int fallback) { + if (!sa.hasParam(param)) { + return fallback; + } + try { + return Integer.parseInt(sa.getParam(param)); + } catch (NumberFormatException ignored) { + return fallback; + } + } + public static boolean canBeKilledByRoyalAssassin(final Player ai, final Card card) { boolean wasTapped = card.isTapped(); for (Player opp : ai.getOpponents()) { diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java index 00087d79da4..e486e45d56c 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java @@ -68,7 +68,7 @@ protected AiAbilityDecision canPlay(Player ai, SpellAbility sa) { if ("LivingDeath".equals(aiLogic)) { return SpecialCardAi.LivingDeath.consider(ai, sa); - } else if ("Timetwister".equals(aiLogic)) { + } else if ("Timetwister".equalsIgnoreCase(aiLogic)) { return SpecialCardAi.Timetwister.consider(ai, sa); } else if ("RetDiscardedThisTurn".equals(aiLogic)) { boolean result = !ai.getDiscardedThisTurn().isEmpty() && ai.getGame().getPhaseHandler().is(PhaseType.END_OF_TURN); diff --git a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java index 23d7a118894..2eca543a75c 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java +++ b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java @@ -334,6 +334,9 @@ private AiPlayDecision canPlayAndPayForSim(final SpellAbility sa) { if (shouldWaitForLater(sa)) { return AiPlayDecision.AnotherTime; } + if (ComputerUtilCard.shouldAvoidDrawPunisher(player, sa)) { + return AiPlayDecision.CurseEffects; + } return AiPlayDecision.WillPlay; } diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java index 81676f97669..3f2dfafe352 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java @@ -3,20 +3,25 @@ import java.util.ArrayList; import java.util.List; -import forge.item.PaperCard; -import forge.model.FModel; import org.testng.AssertJUnit; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import forge.ai.AiController; +import forge.ai.AiPlayDecision; +import forge.ai.ComputerUtilCard; +import forge.ai.PlayerControllerAi; import forge.game.Game; import forge.game.card.Card; import forge.game.card.CounterEnumType; import forge.game.combat.Combat; import forge.game.phase.PhaseType; import forge.game.player.Player; +import forge.game.spellability.Spell; import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; +import forge.item.PaperCard; +import forge.model.FModel; public class SpellAbilityPickerSimulationTest extends SimulationTest { @Test @@ -66,6 +71,141 @@ public void testPickingKillingCreature() { AssertJUnit.assertNull(sa.getTargets().getFirstTargetedPlayer()); } + @DataProvider(name = "drawPunisherWheelData") + public static Object[][] drawPunisherWheelData() { + return new Object[][] { + {false, "Wheel of Fortune", ZoneType.Hand, "Xyris, the Writhing Storm"}, + {true, "Timetwister", ZoneType.Hand, "Nekusar, the Mindrazer"}, + {false, "Echo of Eons", ZoneType.Graveyard, "Xyris, the Writhing Storm"}, + {true, "Echo of Eons", ZoneType.Hand, "Nekusar, the Mindrazer"} + }; + } + + @Test(dataProvider = "drawPunisherWheelData") + public void testAiAvoidsWheelEffectsIntoDrawPunisher(boolean useSimulation, String cardName, ZoneType zone, String punisherName) { + Game game = initAndCreateGame(); + Player p = setupAi(game, useSimulation); + + addCards("Island", 6, p); + addCardToZone(cardName, p, zone); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard(punisherName, opponent); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testOptionalFreeCastAvoidsWheelIntoXyris() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + Card wheel = addCardToZone("Wheel of Fate", p, ZoneType.Exile); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + AssertJUnit.assertEquals(AiPlayDecision.CurseEffects, optionalFreeCastDecision(p, wheel)); + } + + @Test + public void testOptionalFreeCastAllowsWheelIntoCommandZoneXyris() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + Card wheel = addCardToZone("Wheel of Fortune", p, ZoneType.Exile); + fillLibrary(p, 7); + + Card xyris = addCardToZone("Xyris, the Writhing Storm", opponentPlayer(game), ZoneType.Command); + opponentPlayer(game).addCommander(xyris); + fillLibrary(opponentPlayer(game), 7); + + moveToMainPhase(game, p); + + AssertJUnit.assertFalse(ComputerUtilCard.shouldAvoidDrawPunisher(p, makeOptionalFreeCast(p, wheel))); + } + + @DataProvider(name = "drawPunisherPermanentData") + public static Object[][] drawPunisherPermanentData() { + return new Object[][] { + {"Mindmoil", "Nekusar, the Mindrazer"}, + {"Teferi's Puzzle Box", "Xyris, the Writhing Storm"}, + {"Arjun, the Shifting Flame", "Kederekt Parasite"} + }; + } + + @Test(dataProvider = "drawPunisherPermanentData") + public void testAiAvoidsDrawEnginePermanentIntoDrawPunisher(String cardName, String punisherName) { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Mountain", 6, p); + addCards("Island", 6, p); + addCardToZone(cardName, p, ZoneType.Hand); + fillHandAndLibrary(p, 6); + + Player opponent = opponentPlayer(game); + addCard(punisherName, opponent); + addCard("Raging Goblin", opponent); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testAiAvoidsCastingSpellWithArjunOutIntoKederektParasite() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCard("Arjun, the Shifting Flame", p); + addCard("Mountain", p); + addCardToZone("Shock", p, ZoneType.Hand); + fillHandAndLibrary(p, 6); + + Player opponent = opponentPlayer(game); + addCard("Kederekt Parasite", opponent); + addCard("Raging Goblin", opponent); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testAiAllowsOwnCommanderDespiteDrawPunisher() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + Card commander = addCardToZone("Arjun, the Shifting Flame", p, ZoneType.Command); + p.addCommander(commander); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + + moveToMainPhase(game, p); + + SpellAbility sa = commander.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + AssertJUnit.assertFalse(ComputerUtilCard.shouldAvoidDrawPunisher(p, sa)); + } + + @DataProvider(name = "aiPickerMode") + public static Object[][] aiPickerMode() { + return new Object[][] { + {false}, + {true} + }; + } + @Test public void testSequenceStartingWithPlayingLand() { Game game = initAndCreateGame(); @@ -939,4 +1079,58 @@ public void testXIsTheLargestPayableCMC(String cardName) { AssertJUnit.assertEquals("Card '%s' was cast with X instead".formatted(cardName), 3, sa.getXManaCostPaid().intValue()); } + private Player setupAi(Game game, boolean useSimulation) { + Player player = aiPlayer(game); + player.setTeam(0); + opponentPlayer(game).setTeam(1); + ai(player).setUseSimulation(useSimulation); + return player; + } + + private Player aiPlayer(Game game) { + return game.getPlayers().get(1); + } + + private Player opponentPlayer(Game game) { + return game.getPlayers().get(0); + } + + private AiController ai(Player player) { + return ((PlayerControllerAi) player.getController()).getAi(); + } + + private void fillLibrary(Player player, int count) { + for (int i = 0; i < count; i++) { + addCardToZone("Runeclaw Bear", player, ZoneType.Library); + } + } + + private void fillHandAndLibrary(Player player, int count) { + for (int i = 0; i < count; i++) { + addCardToZone("Runeclaw Bear", player, ZoneType.Hand); + addCardToZone("Runeclaw Bear", player, ZoneType.Library); + } + } + + private void moveToMainPhase(Game game, Player player) { + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, player); + game.getAction().checkStateEffects(true); + } + + private void assertNoPlayableSpell(Player player) { + List chosen = ai(player).chooseSpellAbilityToPlay(); + AssertJUnit.assertNull(chosen == null ? null : chosen.toString(), chosen); + } + + private Spell makeOptionalFreeCast(Player player, Card card) { + Spell freeCast = (Spell) card.getFirstSpellAbility().copyWithNoManaCost(player); + freeCast.setActivatingPlayer(player); + freeCast.setCastFromPlayEffect(true); + return freeCast; + } + + private AiPlayDecision optionalFreeCastDecision(Player player, Card card) { + return ai(player).canPlayFromEffectAI(makeOptionalFreeCast(player, card), false, true); + } + } From 495affceefabb49e8fa5b101ec5d85473aa3c4ff Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Fri, 1 May 2026 12:12:22 -0700 Subject: [PATCH 2/3] Cleanup pass --- .../main/java/forge/ai/ComputerUtilCard.java | 28 ++++++++----------- .../SpellAbilityPickerSimulationTest.java | 8 ------ 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index 4106fd4b7c8..def74745be5 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -918,38 +918,31 @@ public static boolean shouldAvoidDrawPunisher(final Player ai, final SpellAbilit } private static int estimateSelfDrawsFromPlay(final Player ai, final SpellAbility sa) { - int draws = estimateSelfDrawsFromAbility(ai, sa, ai.getCardsIn(ZoneType.Hand).size()); + final int handSize = ai.getCardsIn(ZoneType.Hand).size(); + int draws = estimateSelfDrawsFromAbility(ai, sa, handSize); if (sa.isSpell()) { for (final Card card : ai.getCardsIn(ZoneType.Battlefield)) { - draws = Math.max(draws, estimateSelfDrawsFromSpellCastTriggers(ai, card, ai.getCardsIn(ZoneType.Hand).size())); + draws = Math.max(draws, estimateSelfDrawsFromTriggers(ai, card, handSize, false)); } } final Card host = sa.getHostCard(); if (host != null) { - draws = Math.max(draws, estimateSelfDrawsFromCardTriggers(ai, host)); + draws = Math.max(draws, estimateSelfDrawsFromTriggers(ai, host, handSize, true)); } return draws; } - private static int estimateSelfDrawsFromCardTriggers(final Player ai, final Card card) { + private static int estimateSelfDrawsFromTriggers(final Player ai, final Card card, + final int fallbackDraws, final boolean includeDrawStepTriggers) { int draws = 0; for (final Trigger trigger : card.getTriggers()) { if (trigger.getMode() == TriggerType.SpellCast && triggerMatchesControllerSpellCast(trigger)) { - draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), ai.getCardsIn(ZoneType.Hand).size())); - } else if (trigger.getMode() == TriggerType.Phase && "Draw".equals(trigger.getParam("Phase")) + draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), fallbackDraws)); + } else if (includeDrawStepTriggers && trigger.getMode() == TriggerType.Phase + && "Draw".equals(trigger.getParam("Phase")) && triggerMayAffectPlayer(trigger, ai)) { - draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), ai.getCardsIn(ZoneType.Hand).size())); - } - } - return draws; - } - - private static int estimateSelfDrawsFromSpellCastTriggers(final Player ai, final Card card, final int fallbackDraws) { - int draws = 0; - for (final Trigger trigger : card.getTriggers()) { - if (trigger.getMode() == TriggerType.SpellCast && triggerMatchesControllerSpellCast(trigger)) { draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), fallbackDraws)); } } @@ -1024,7 +1017,8 @@ private static int estimateDrawAmount(final Player ai, final SpellAbility sa, fi } private static boolean isOwnCommanderFromCommandZone(final Player ai, final Card host) { - return host.isCommander() && host.isInZone(ZoneType.Command) && host.getOwner().equals(ai); + return host.isCommander() && host.isInZone(ZoneType.Command) + && (host.getOwner().equals(ai) || ai.getCommanders().contains(host)); } private static boolean opponentHasDrawPunisher(final Player ai, final Player opponent, final int estimatedDraws) { diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java index 3f2dfafe352..d97eaf79722 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java @@ -198,14 +198,6 @@ public void testAiAllowsOwnCommanderDespiteDrawPunisher() { AssertJUnit.assertFalse(ComputerUtilCard.shouldAvoidDrawPunisher(p, sa)); } - @DataProvider(name = "aiPickerMode") - public static Object[][] aiPickerMode() { - return new Object[][] { - {false}, - {true} - }; - } - @Test public void testSequenceStartingWithPlayingLand() { Game game = initAndCreateGame(); From 4d773756a14924a014940eef4cccc65963a1f83c Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Fri, 1 May 2026 13:44:44 -0700 Subject: [PATCH 3/3] Keep draw punisher heuristics out of simulation --- .../forge/ai/simulation/SpellAbilityPicker.java | 3 --- .../SpellAbilityPickerSimulationTest.java | 14 +++++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java index 2eca543a75c..23d7a118894 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java +++ b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java @@ -334,9 +334,6 @@ private AiPlayDecision canPlayAndPayForSim(final SpellAbility sa) { if (shouldWaitForLater(sa)) { return AiPlayDecision.AnotherTime; } - if (ComputerUtilCard.shouldAvoidDrawPunisher(player, sa)) { - return AiPlayDecision.CurseEffects; - } return AiPlayDecision.WillPlay; } diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java index d97eaf79722..e0434edd466 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java @@ -74,19 +74,19 @@ public void testPickingKillingCreature() { @DataProvider(name = "drawPunisherWheelData") public static Object[][] drawPunisherWheelData() { return new Object[][] { - {false, "Wheel of Fortune", ZoneType.Hand, "Xyris, the Writhing Storm"}, - {true, "Timetwister", ZoneType.Hand, "Nekusar, the Mindrazer"}, - {false, "Echo of Eons", ZoneType.Graveyard, "Xyris, the Writhing Storm"}, - {true, "Echo of Eons", ZoneType.Hand, "Nekusar, the Mindrazer"} + {"Wheel of Fortune", ZoneType.Hand, "Xyris, the Writhing Storm"}, + {"Timetwister", ZoneType.Hand, "Nekusar, the Mindrazer"}, + {"Echo of Eons", ZoneType.Graveyard, "Xyris, the Writhing Storm"}, + {"Echo of Eons", ZoneType.Hand, "Nekusar, the Mindrazer"} }; } @Test(dataProvider = "drawPunisherWheelData") - public void testAiAvoidsWheelEffectsIntoDrawPunisher(boolean useSimulation, String cardName, ZoneType zone, String punisherName) { + public void testAiAvoidsWheelEffectsIntoDrawPunisher(String cardName, ZoneType zone, String punisherName) { Game game = initAndCreateGame(); - Player p = setupAi(game, useSimulation); + Player p = setupAi(game, false); - addCards("Island", 6, p); + addCards("City of Brass", 6, p); addCardToZone(cardName, p, zone); fillLibrary(p, 7);