AI: fixed game freeze on cards with combat triggers (close #13342)

This commit is contained in:
Oleg Agafonov 2025-04-09 02:21:16 +04:00
parent 04f9ab75ba
commit 0a1bf47434
3 changed files with 78 additions and 32 deletions

View file

@ -380,9 +380,13 @@ public class HumanPlayer extends PlayerImpl {
} }
} }
private boolean canCallFeedback(Game game) {
return !gameInCheckPlayableState(game) && !game.isSimulation();
}
@Override @Override
public boolean chooseMulligan(Game game) { public boolean chooseMulligan(Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -509,7 +513,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public int chooseReplacementEffect(Map<String, String> effectsMap, Map<String, MageObject> objectsMap, Game game) { public int chooseReplacementEffect(Map<String, String> effectsMap, Map<String, MageObject> 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; return 0;
} }
@ -615,7 +619,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public boolean choose(Outcome outcome, Choice choice, Game game) { public boolean choose(Outcome outcome, Choice choice, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -678,7 +682,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public boolean choose(Outcome outcome, Target target, Ability source, Game game, Map<String, Serializable> options) { public boolean choose(Outcome outcome, Target target, Ability source, Game game, Map<String, Serializable> options) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -782,7 +786,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) { public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -862,7 +866,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public boolean choose(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { public boolean choose(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -944,7 +948,7 @@ public class HumanPlayer extends PlayerImpl {
// choose one or multiple target cards // choose one or multiple target cards
@Override @Override
public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -1025,7 +1029,7 @@ public class HumanPlayer extends PlayerImpl {
public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) { public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) {
// choose amount // choose amount
// human can choose or un-choose MULTIPLE targets at once // human can choose or un-choose MULTIPLE targets at once
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -1486,8 +1490,8 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public TriggeredAbility chooseTriggeredAbility(java.util.List<TriggeredAbility> abilities, Game game) { public TriggeredAbility chooseTriggeredAbility(java.util.List<TriggeredAbility> abilities, Game game) {
// choose triggered abilitity from list // choose triggered abilitity from list
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return null; return abilities.isEmpty() ? null : abilities.get(0);
} }
// automatically order triggers with same ability, rules text, and targets // 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) { protected boolean playManaHandling(Ability abilityToCast, ManaCost unpaid, String promptText, Game game) {
// choose mana to pay (from permanents or from pool) // choose mana to pay (from permanents or from pool)
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }
@ -1661,7 +1665,7 @@ public class HumanPlayer extends PlayerImpl {
* @return * @return
*/ */
public int announceRepetitions(Game game) { public int announceRepetitions(Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return 0; return 0;
} }
@ -1694,8 +1698,8 @@ public class HumanPlayer extends PlayerImpl {
*/ */
@Override @Override
public int announceXMana(int min, int max, String message, Game game, Ability ability) { public int announceXMana(int min, int max, String message, Game game, Ability ability) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return 0; return min;
} }
int xValue = 0; int xValue = 0;
@ -1721,8 +1725,8 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public int announceXCost(int min, int max, String message, Game game, Ability ability, VariableCost variableCost) { public int announceXCost(int min, int max, String message, Game game, Ability ability, VariableCost variableCost) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return 0; return min;
} }
int xValue = 0; int xValue = 0;
@ -1794,7 +1798,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public void selectAttackers(Game game, UUID attackingPlayerId) { public void selectAttackers(Game game, UUID attackingPlayerId) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return; return;
} }
@ -2064,7 +2068,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public void selectBlockers(Ability source, Game game, UUID defendingPlayerId) { public void selectBlockers(Ability source, Game game, UUID defendingPlayerId) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return; return;
} }
@ -2126,7 +2130,7 @@ public class HumanPlayer extends PlayerImpl {
} }
protected void selectCombatGroup(UUID defenderId, UUID blockerId, Game game) { protected void selectCombatGroup(UUID defenderId, UUID blockerId, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return; return;
} }
TargetAttackingCreature target = new TargetAttackingCreature(); TargetAttackingCreature target = new TargetAttackingCreature();
@ -2180,8 +2184,8 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public int getAmount(int min, int max, String message, Game game) { public int getAmount(int min, int max, String message, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return 0; return min;
} }
while (canRespond()) { while (canRespond()) {
@ -2220,7 +2224,7 @@ public class HumanPlayer extends PlayerImpl {
return defaultList; return defaultList;
} }
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return defaultList; return defaultList;
} }
@ -2288,7 +2292,7 @@ public class HumanPlayer extends PlayerImpl {
* @param unpaidForManaAction - set unpaid for mana actions like convoke * @param unpaidForManaAction - set unpaid for mana actions like convoke
*/ */
protected void activateSpecialAction(Game game, ManaCost unpaidForManaAction) { protected void activateSpecialAction(Game game, ManaCost unpaidForManaAction) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return; return;
} }
@ -2319,7 +2323,7 @@ public class HumanPlayer extends PlayerImpl {
} }
protected void activateAbility(Map<UUID, ? extends ActivatedAbility> abilities, MageObject object, Game game) { protected void activateAbility(Map<UUID, ? extends ActivatedAbility> abilities, MageObject object, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return; return;
} }
@ -2404,7 +2408,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public SpellAbility chooseAbilityForCast(Card card, Game game, boolean noMana) { public SpellAbility chooseAbilityForCast(Card card, Game game, boolean noMana) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return null; return null;
} }
@ -2444,7 +2448,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public ActivatedAbility chooseLandOrSpellAbility(Card card, Game game, boolean noMana) { public ActivatedAbility chooseLandOrSpellAbility(Card card, Game game, boolean noMana) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return null; return null;
} }
@ -2491,7 +2495,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public Mode chooseMode(Modes modes, Ability source, Game game) { public Mode chooseMode(Modes modes, Ability source, Game game) {
// choose mode to activate // choose mode to activate
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return null; return null;
} }
@ -2619,7 +2623,7 @@ public class HumanPlayer extends PlayerImpl {
@Override @Override
public boolean choosePile(Outcome outcome, String message, java.util.List<? extends Card> pile1, java.util.List<? extends Card> pile2, Game game) { public boolean choosePile(Outcome outcome, String message, java.util.List<? extends Card> pile1, java.util.List<? extends Card> pile2, Game game) {
if (gameInCheckPlayableState(game)) { if (!canCallFeedback(game)) {
return true; return true;
} }

View file

@ -2,9 +2,9 @@ package mage.cards.f;
import mage.MageInt; import mage.MageInt;
import mage.abilities.Ability; import mage.abilities.Ability;
import mage.abilities.triggers.BeginningOfUpkeepTriggeredAbility;
import mage.abilities.effects.OneShotEffect; import mage.abilities.effects.OneShotEffect;
import mage.abilities.keyword.MenaceAbility; import mage.abilities.keyword.MenaceAbility;
import mage.abilities.triggers.BeginningOfUpkeepTriggeredAbility;
import mage.cards.CardImpl; import mage.cards.CardImpl;
import mage.cards.CardSetInfo; import mage.cards.CardSetInfo;
import mage.constants.*; import mage.constants.*;
@ -23,11 +23,10 @@ public class FurnacePunisher extends CardImpl {
this.power = new MageInt(3); this.power = new MageInt(3);
this.toughness = new MageInt(3); this.toughness = new MageInt(3);
//Menace // Menace
this.addAbility(new MenaceAbility(false)); this.addAbility(new MenaceAbility(false));
//At the beginning of each players upkeep, Furnace Punisher deals 2 damage to that player unless they control // At the beginning of each players upkeep, Furnace Punisher deals 2 damage to that player unless they control two or more basic lands.
//two or more basic lands.
this.addAbility(new BeginningOfUpkeepTriggeredAbility(TargetController.EACH_PLAYER, new FurnacePunisherEffect(), this.addAbility(new BeginningOfUpkeepTriggeredAbility(TargetController.EACH_PLAYER, new FurnacePunisherEffect(),
false false
).setTriggerPhrase("At the beginning of each player's upkeep, ")); ).setTriggerPhrase("At the beginning of each player's upkeep, "));

View file

@ -309,4 +309,47 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps {
assertGraveyardCount(playerA, "Balduvian Bears", 1); assertGraveyardCount(playerA, "Balduvian Bears", 1);
assertDamageReceived(playerB, "Graveblade Marauder", 2); 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 players 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);
}
} }