Fix AI crashing server on too many target calculations. Closes #9539, #14031 (#14044)

This commit is contained in:
PurpleCrowbar 2025-10-24 16:02:44 +01:00 committed by GitHub
parent 4a458800aa
commit b839c7bf87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 112 additions and 77 deletions

View file

@ -50,21 +50,29 @@ public class SimulationTriggersAITest extends CardTestPlayerBaseWithAIHelps {
@Test @Test
public void test_DeepglowSkate_PerformanceOnTooManyChoices() { public void test_DeepglowSkate_PerformanceOnTooManyChoices() {
// bug: game freeze with 100% CPU usage
// https://github.com/magefree/mage/issues/9438 // https://github.com/magefree/mage/issues/9438
int cardsCount = 2; // 2+ cards will generate too much target options for simulations int quantity = 1;
int boostMultiplier = (int) Math.pow(2, cardsCount); String[] cardNames = {
"Island", "Plains", "Swamp", "Mountain",
"Runeclaw Bear", "Absolute Law", "Gilded Lotus", "Alpha Myr"
};
// When Deepglow Skate enters the battlefield, double the number of each kind of counter on any number // When Deepglow Skate enters the battlefield, double the number of each kind of counter on any number
// of target permanents. // of target permanents.
addCard(Zone.HAND, playerA, "Deepglow Skate", cardsCount); // {4}{U} addCard(Zone.HAND, playerA, "Deepglow Skate", 1); // {4}{U}
addCard(Zone.BATTLEFIELD, playerA, "Island", 5 * cardsCount); // Bloat the battlefield with permanents (possible targets)
// for (String card : cardNames) {
addCard(Zone.BATTLEFIELD, playerA, card, quantity);
addCard(Zone.BATTLEFIELD, playerB, card, quantity);
addCard(Zone.BATTLEFIELD, playerC, card, quantity);
addCard(Zone.BATTLEFIELD, playerD, card, quantity);
}
addCard(Zone.BATTLEFIELD, playerA, "Ajani, Adversary of Tyrants", 1); // x4 loyalty addCard(Zone.BATTLEFIELD, playerA, "Ajani, Adversary of Tyrants", 1); // x4 loyalty
addCard(Zone.BATTLEFIELD, playerA, "Ajani, Caller of the Pride", 1); // x4 loyalty addCard(Zone.BATTLEFIELD, playerA, "Ajani, Caller of the Pride", 1); // x4 loyalty
addCard(Zone.BATTLEFIELD, playerB, "Ajani Goldmane", 1); // x4 loyalty addCard(Zone.BATTLEFIELD, playerB, "Ajani Goldmane", 1); // x4 loyalty
addCard(Zone.BATTLEFIELD, playerB, "Ajani, Inspiring Leader", 1); // x5 loyalty addCard(Zone.BATTLEFIELD, playerB, "Ajani, Inspiring Leader", 1); // x5 loyalty
//
// Players can't activate planeswalkers' loyalty abilities. // Players can't activate planeswalkers' loyalty abilities.
addCard(Zone.BATTLEFIELD, playerA, "The Immortal Sun", 1); // disable planeswalkers usage by AI addCard(Zone.BATTLEFIELD, playerA, "The Immortal Sun", 1); // disable planeswalkers usage by AI
@ -75,9 +83,9 @@ public class SimulationTriggersAITest extends CardTestPlayerBaseWithAIHelps {
setStopAt(1, PhaseStep.END_TURN); setStopAt(1, PhaseStep.END_TURN);
execute(); execute();
assertPermanentCount(playerA, "Deepglow Skate", cardsCount); assertPermanentCount(playerA, "Deepglow Skate", 1);
assertCounterCount(playerA, "Ajani, Adversary of Tyrants", CounterType.LOYALTY, 4 * boostMultiplier); assertCounterCount(playerA, "Ajani, Adversary of Tyrants", CounterType.LOYALTY, 4 * 2);
assertCounterCount(playerA, "Ajani, Caller of the Pride", CounterType.LOYALTY, 4 * boostMultiplier); assertCounterCount(playerA, "Ajani, Caller of the Pride", CounterType.LOYALTY, 4 * 2);
assertCounterCount(playerB, "Ajani Goldmane", CounterType.LOYALTY, 4); assertCounterCount(playerB, "Ajani Goldmane", CounterType.LOYALTY, 4);
assertCounterCount(playerB, "Ajani, Inspiring Leader", CounterType.LOYALTY, 5); assertCounterCount(playerB, "Ajani, Inspiring Leader", CounterType.LOYALTY, 5);
} }

View file

@ -26,54 +26,19 @@ public class TargetOptimization {
// for up to or any amount - limit max game sims to analyse // for up to or any amount - limit max game sims to analyse
// (it's useless to calc all possible combinations on too much targets) // (it's useless to calc all possible combinations on too much targets)
static public int AI_MAX_POSSIBLE_TARGETS_TO_CHOOSE = 7; static public int AI_MAX_POSSIBLE_TARGETS_TO_CHOOSE = 18;
public static void optimizePossibleTargets(Ability source, Game game, Set<UUID> possibleTargets, int maxPossibleTargetsToSimulate) { public static void optimizePossibleTargets(Ability source, Game game, Set<UUID> possibleTargets, int maxPossibleTargetsToSimulate) {
// remove duplicated/same creatures // remove duplicated/same creatures
// example: distribute 3 damage between 10+ same tokens // example: distribute 3 damage between 10+ same tokens
// example: target x1 from x10 forests - it's useless to recalc each forest // example: target x1 from x10 forests - it's useless to recalc each forest
if (possibleTargets.size() < maxPossibleTargetsToSimulate) { if (possibleTargets.size() <= maxPossibleTargetsToSimulate) {
return; return;
} }
// split targets by groups // split targets by groups
Map<UUID, String> targetGroups = new HashMap<>(); Map<String, ArrayList<UUID>> targetGroups = createGroups(game, possibleTargets, maxPossibleTargetsToSimulate, false);
possibleTargets.forEach(id -> {
String groupKey = "";
// player
Player player = game.getPlayer(id);
if (player != null) {
groupKey = getTargetGroupKeyAsPlayer(player);
}
// game object
MageObject object = game.getObject(id);
if (object != null) {
groupKey = object.getName();
if (object instanceof Permanent) {
groupKey += getTargetGroupKeyAsPermanent(game, (Permanent) object);
} else if (object instanceof Card) {
groupKey += getTargetGroupKeyAsCard(game, (Card) object);
} else {
groupKey += getTargetGroupKeyAsOther(game, object);
}
}
// unknown - use all
if (groupKey.isEmpty()) {
groupKey = id.toString();
}
targetGroups.put(id, groupKey);
});
Map<String, List<UUID>> groups = new HashMap<>();
targetGroups.forEach((id, groupKey) -> {
groups.computeIfAbsent(groupKey, k -> new ArrayList<>());
groups.get(groupKey).add(id);
});
// optimize logic: // optimize logic:
// - use one target from each target group all the time // - use one target from each target group all the time
@ -81,7 +46,7 @@ public class TargetOptimization {
// use one target per group // use one target per group
Set<UUID> newPossibleTargets = new HashSet<>(); Set<UUID> newPossibleTargets = new HashSet<>();
groups.forEach((groupKey, groupTargets) -> { targetGroups.forEach((groupKey, groupTargets) -> {
UUID targetId = RandomUtil.randomFromCollection(groupTargets); UUID targetId = RandomUtil.randomFromCollection(groupTargets);
if (targetId != null) { if (targetId != null) {
newPossibleTargets.add(targetId); newPossibleTargets.add(targetId);
@ -91,13 +56,13 @@ public class TargetOptimization {
// use random target until fill condition // use random target until fill condition
while (newPossibleTargets.size() < maxPossibleTargetsToSimulate) { while (newPossibleTargets.size() < maxPossibleTargetsToSimulate) {
String groupKey = RandomUtil.randomFromCollection(groups.keySet()); String groupKey = RandomUtil.randomFromCollection(targetGroups.keySet());
if (groupKey == null) { if (groupKey == null) {
break; break;
} }
List<UUID> groupTargets = groups.getOrDefault(groupKey, null); List<UUID> groupTargets = targetGroups.getOrDefault(groupKey, null);
if (groupTargets == null || groupTargets.isEmpty()) { if (groupTargets == null || groupTargets.isEmpty()) {
groups.remove(groupKey); targetGroups.remove(groupKey);
continue; continue;
} }
UUID targetId = RandomUtil.randomFromCollection(groupTargets); UUID targetId = RandomUtil.randomFromCollection(groupTargets);
@ -112,6 +77,49 @@ public class TargetOptimization {
possibleTargets.addAll(newPossibleTargets); possibleTargets.addAll(newPossibleTargets);
} }
private static Map<String, ArrayList<UUID>> createGroups(Game game, Set<UUID> possibleTargets, int maxPossibleTargetsToSimulate, boolean isLoose) {
Map<String, ArrayList<UUID>> targetGroups = new HashMap<>();
possibleTargets.forEach(id -> {
String groupKey = "";
// player
Player player = game.getPlayer(id);
if (player != null) {
groupKey = getTargetGroupKeyAsPlayer(player);
}
// game object
MageObject object = game.getObject(id);
if (object != null) {
groupKey = object.getName();
if (object instanceof Permanent) {
groupKey += getTargetGroupKeyAsPermanent(game, (Permanent) object, isLoose);
} else if (object instanceof Card) {
groupKey += getTargetGroupKeyAsCard(game, (Card) object, isLoose);
} else {
groupKey += getTargetGroupKeyAsOther(game, object);
}
}
// unknown - use all
if (groupKey.isEmpty()) {
groupKey = id.toString();
}
targetGroups.computeIfAbsent(groupKey, k -> new ArrayList<>()).add(id);
});
if (targetGroups.size() > maxPossibleTargetsToSimulate && !isLoose) {
// If too many possible target groups, regroup with less specific characteristics
return createGroups(game, possibleTargets, maxPossibleTargetsToSimulate, true);
}
// Return appropriate target groups or, if still too many possible targets after loose grouping,
// allow optimizePossibleTargets (defined above) to choose random targets within limit
return targetGroups;
}
private static String getTargetGroupKeyAsPlayer(Player player) { private static String getTargetGroupKeyAsPlayer(Player player) {
// use all // use all
return String.join(";", Arrays.asList( return String.join(";", Arrays.asList(
@ -120,38 +128,57 @@ public class TargetOptimization {
)); ));
} }
private static String getTargetGroupKeyAsPermanent(Game game, Permanent permanent) { private static String getTargetGroupKeyAsPermanent(Game game, Permanent permanent, boolean isLoose) {
// split by name and stats // split by name and stats
// TODO: rework and combine with PermanentEvaluator (to use battlefield score) // TODO: rework and combine with PermanentEvaluator (to use battlefield score)
// try to use short text/hash for lesser data on debug // try to use short text/hash for lesser data on debug
return String.join(";", Arrays.asList( if (!isLoose) {
permanent.getName(), return String.join(";", Arrays.asList(
String.valueOf(permanent.getControllerId().hashCode()), permanent.getName(),
String.valueOf(permanent.getOwnerId().hashCode()), String.valueOf(permanent.getControllerId().hashCode()),
String.valueOf(permanent.isTapped()), String.valueOf(permanent.getOwnerId().hashCode()),
String.valueOf(permanent.getPower().getValue()), String.valueOf(permanent.isTapped()),
String.valueOf(permanent.getToughness().getValue()), String.valueOf(permanent.getPower().getValue()),
String.valueOf(permanent.getDamage()), String.valueOf(permanent.getToughness().getValue()),
String.valueOf(permanent.getCardType(game).toString().hashCode()), String.valueOf(permanent.getDamage()),
String.valueOf(permanent.getSubtype(game).toString().hashCode()), String.valueOf(permanent.getCardType(game).toString().hashCode()),
String.valueOf(permanent.getCounters(game).getTotalCount()), String.valueOf(permanent.getSubtype(game).toString().hashCode()),
String.valueOf(permanent.getAbilities(game).size()), String.valueOf(permanent.getCounters(game).getTotalCount()),
String.valueOf(permanent.getRules(game).toString().hashCode()) String.valueOf(permanent.getAbilities(game).size()),
)); String.valueOf(permanent.getRules(game).toString().hashCode())
));
}
else {
return String.join(";", Arrays.asList(
String.valueOf(permanent.getControllerId().hashCode()),
String.valueOf(permanent.getPower().getValue()),
String.valueOf(permanent.getToughness().getValue()),
String.valueOf(permanent.getDamage()),
String.valueOf(permanent.getCardType(game).toString().hashCode())
));
}
} }
private static String getTargetGroupKeyAsCard(Game game, Card card) { private static String getTargetGroupKeyAsCard(Game game, Card card, boolean isLoose) {
// split by name and stats // split by name and stats
return String.join(";", Arrays.asList( if (!isLoose) {
card.getName(), return String.join(";", Arrays.asList(
String.valueOf(card.getOwnerId().hashCode()), card.getName(),
String.valueOf(card.getCardType(game).toString().hashCode()), String.valueOf(card.getOwnerId().hashCode()),
String.valueOf(card.getSubtype(game).toString().hashCode()), String.valueOf(card.getCardType(game).toString().hashCode()),
String.valueOf(card.getCounters(game).getTotalCount()), String.valueOf(card.getSubtype(game).toString().hashCode()),
String.valueOf(card.getAbilities(game).size()), String.valueOf(card.getCounters(game).getTotalCount()),
String.valueOf(card.getRules(game).toString().hashCode()) String.valueOf(card.getAbilities(game).size()),
)); String.valueOf(card.getRules(game).toString().hashCode())
));
}
else {
return String.join(";", Arrays.asList(
String.valueOf(card.getOwnerId().hashCode()),
String.valueOf(card.getCardType(game).toString().hashCode())
));
}
} }
private static String getTargetGroupKeyAsOther(Game game, MageObject item) { private static String getTargetGroupKeyAsOther(Game game, MageObject item) {