From 0a1bf474349d6e600cfddeb8dc30c727f1ed774f Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Wed, 9 Apr 2025 02:21:16 +0400 Subject: [PATCH] AI: fixed game freeze on cards with combat triggers (close #13342) --- .../src/mage/player/human/HumanPlayer.java | 60 ++++++++++--------- .../src/mage/cards/f/FurnacePunisher.java | 7 +-- .../test/AI/basic/BlockSimulationAITest.java | 43 +++++++++++++ 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java index 46bafaf3eb4..9f64a00117c 100644 --- a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java +++ b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java @@ -380,9 +380,13 @@ public class HumanPlayer extends PlayerImpl { } } + private boolean canCallFeedback(Game game) { + return !gameInCheckPlayableState(game) && !game.isSimulation(); + } + @Override public boolean chooseMulligan(Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -509,7 +513,7 @@ public class HumanPlayer extends PlayerImpl { @Override public int chooseReplacementEffect(Map effectsMap, Map objectsMap, Game game) { - if (gameInCheckPlayableState(game, true)) { // ignore warning logs until double call for TAPPED_FOR_MANA will be fix + if (gameInCheckPlayableState(game, true) || game.isSimulation()) { // TODO: ignore warning logs until double call for TAPPED_FOR_MANA will be fix return 0; } @@ -615,7 +619,7 @@ public class HumanPlayer extends PlayerImpl { @Override public boolean choose(Outcome outcome, Choice choice, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -678,7 +682,7 @@ public class HumanPlayer extends PlayerImpl { @Override public boolean choose(Outcome outcome, Target target, Ability source, Game game, Map options) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -782,7 +786,7 @@ public class HumanPlayer extends PlayerImpl { @Override public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -862,7 +866,7 @@ public class HumanPlayer extends PlayerImpl { @Override public boolean choose(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -944,7 +948,7 @@ public class HumanPlayer extends PlayerImpl { // choose one or multiple target cards @Override public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -1025,7 +1029,7 @@ public class HumanPlayer extends PlayerImpl { public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) { // choose amount // human can choose or un-choose MULTIPLE targets at once - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -1486,8 +1490,8 @@ public class HumanPlayer extends PlayerImpl { @Override public TriggeredAbility chooseTriggeredAbility(java.util.List abilities, Game game) { // choose triggered abilitity from list - if (gameInCheckPlayableState(game)) { - return null; + if (!canCallFeedback(game)) { + return abilities.isEmpty() ? null : abilities.get(0); } // automatically order triggers with same ability, rules text, and targets @@ -1615,7 +1619,7 @@ public class HumanPlayer extends PlayerImpl { protected boolean playManaHandling(Ability abilityToCast, ManaCost unpaid, String promptText, Game game) { // choose mana to pay (from permanents or from pool) - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } @@ -1661,7 +1665,7 @@ public class HumanPlayer extends PlayerImpl { * @return */ public int announceRepetitions(Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return 0; } @@ -1694,8 +1698,8 @@ public class HumanPlayer extends PlayerImpl { */ @Override public int announceXMana(int min, int max, String message, Game game, Ability ability) { - if (gameInCheckPlayableState(game)) { - return 0; + if (!canCallFeedback(game)) { + return min; } int xValue = 0; @@ -1721,8 +1725,8 @@ public class HumanPlayer extends PlayerImpl { @Override public int announceXCost(int min, int max, String message, Game game, Ability ability, VariableCost variableCost) { - if (gameInCheckPlayableState(game)) { - return 0; + if (!canCallFeedback(game)) { + return min; } int xValue = 0; @@ -1794,7 +1798,7 @@ public class HumanPlayer extends PlayerImpl { @Override public void selectAttackers(Game game, UUID attackingPlayerId) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return; } @@ -2064,7 +2068,7 @@ public class HumanPlayer extends PlayerImpl { @Override public void selectBlockers(Ability source, Game game, UUID defendingPlayerId) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return; } @@ -2126,7 +2130,7 @@ public class HumanPlayer extends PlayerImpl { } protected void selectCombatGroup(UUID defenderId, UUID blockerId, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return; } TargetAttackingCreature target = new TargetAttackingCreature(); @@ -2180,8 +2184,8 @@ public class HumanPlayer extends PlayerImpl { @Override public int getAmount(int min, int max, String message, Game game) { - if (gameInCheckPlayableState(game)) { - return 0; + if (!canCallFeedback(game)) { + return min; } while (canRespond()) { @@ -2220,7 +2224,7 @@ public class HumanPlayer extends PlayerImpl { return defaultList; } - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return defaultList; } @@ -2288,7 +2292,7 @@ public class HumanPlayer extends PlayerImpl { * @param unpaidForManaAction - set unpaid for mana actions like convoke */ protected void activateSpecialAction(Game game, ManaCost unpaidForManaAction) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return; } @@ -2319,7 +2323,7 @@ public class HumanPlayer extends PlayerImpl { } protected void activateAbility(Map abilities, MageObject object, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return; } @@ -2404,7 +2408,7 @@ public class HumanPlayer extends PlayerImpl { @Override public SpellAbility chooseAbilityForCast(Card card, Game game, boolean noMana) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return null; } @@ -2444,7 +2448,7 @@ public class HumanPlayer extends PlayerImpl { @Override public ActivatedAbility chooseLandOrSpellAbility(Card card, Game game, boolean noMana) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return null; } @@ -2491,7 +2495,7 @@ public class HumanPlayer extends PlayerImpl { @Override public Mode chooseMode(Modes modes, Ability source, Game game) { // choose mode to activate - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return null; } @@ -2619,7 +2623,7 @@ public class HumanPlayer extends PlayerImpl { @Override public boolean choosePile(Outcome outcome, String message, java.util.List pile1, java.util.List pile2, Game game) { - if (gameInCheckPlayableState(game)) { + if (!canCallFeedback(game)) { return true; } diff --git a/Mage.Sets/src/mage/cards/f/FurnacePunisher.java b/Mage.Sets/src/mage/cards/f/FurnacePunisher.java index a086a692657..88d6854e5db 100644 --- a/Mage.Sets/src/mage/cards/f/FurnacePunisher.java +++ b/Mage.Sets/src/mage/cards/f/FurnacePunisher.java @@ -2,9 +2,9 @@ package mage.cards.f; import mage.MageInt; import mage.abilities.Ability; -import mage.abilities.triggers.BeginningOfUpkeepTriggeredAbility; import mage.abilities.effects.OneShotEffect; import mage.abilities.keyword.MenaceAbility; +import mage.abilities.triggers.BeginningOfUpkeepTriggeredAbility; import mage.cards.CardImpl; import mage.cards.CardSetInfo; import mage.constants.*; @@ -23,11 +23,10 @@ public class FurnacePunisher extends CardImpl { this.power = new MageInt(3); this.toughness = new MageInt(3); - //Menace + // Menace this.addAbility(new MenaceAbility(false)); - //At the beginning of each player’s upkeep, Furnace Punisher deals 2 damage to that player unless they control - //two or more basic lands. + // At the beginning of each player’s upkeep, Furnace Punisher deals 2 damage to that player unless they control two or more basic lands. this.addAbility(new BeginningOfUpkeepTriggeredAbility(TargetController.EACH_PLAYER, new FurnacePunisherEffect(), false ).setTriggerPhrase("At the beginning of each player's upkeep, ")); diff --git a/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java b/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java index af1e99a689e..f7c0158b60d 100644 --- a/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java +++ b/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java @@ -309,4 +309,47 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { assertGraveyardCount(playerA, "Balduvian Bears", 1); assertDamageReceived(playerB, "Graveblade Marauder", 2); } + + @Test + public void test_Block_deathtouch_attacker_vs_menace() { + // possible bug: AI freeze, see https://github.com/magefree/mage/issues/13342 + // it's only HumanPlayer related and can't be tested here + + // First strike, Deathtouch + // Whenever Glissa Sunslayer deals combat damage to a player, choose one — + // • You draw a card and you lose 1 life. + // • Destroy target enchantment. + // • Remove up to three counters from target permanent. + addCard(Zone.BATTLEFIELD, playerA, "Glissa Sunslayer", 1); // 3/3 + // Deathtouch + // Whenever a creature you control with deathtouch deals combat damage to a player, that player gets two poison counters. + addCard(Zone.BATTLEFIELD, playerA, "Fynn, the Fangbearer", 1); // 1/3 + // Deathtouch + // Toxic 1 (Players dealt combat damage by this creature also get a poison counter.) + addCard(Zone.BATTLEFIELD, playerA, "Bilious Skulldweller", 1); // 1/1 + // + // Menace + // At the beginning of each player’s upkeep, Furnace Punisher deals 2 damage to that player unless they control + // two or more basic lands. + addCard(Zone.BATTLEFIELD, playerB, "Furnace Punisher", 1); // 3/3 + + attack(1, playerA, "Glissa Sunslayer"); + attack(1, playerA, "Fynn, the Fangbearer"); + attack(1, playerA, "Bilious Skulldweller"); + setChoice(playerA, "Whenever a creature you control", 2); // x3 triggers + setModeChoice(playerA, "1"); // you draw a card and you lose 1 life + + // ai must not block attacker with Deathtouch + aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("no blockers", 1, playerB, ""); + + setStopAt(1, PhaseStep.END_TURN); + setStrictChooseMode(true); + execute(); + + assertLife(playerA, 20 - 1 - 2); // from draw, from furnace damage + assertLife(playerB, 20 - 3 - 1 - 1); + assertPermanentCount(playerA, "Glissa Sunslayer", 1); + assertGraveyardCount(playerB, 0); + } }