AI: improved performance and fixed crashes on use cases with too much target options like "deals 5 damage divided as you choose" (related to #11285):

* added DebugUtil.AI_ENABLE_DEBUG_MODE for better IDE's debugging AI code;
 * it's a target amount optimizations;
 * it's use a grouping of possible targets due same static and dynamic stats (name, abilities, rules, damage, etc);
 * instead of going through all possible combinations, AI uses only meaningful targets from particular groups;
This commit is contained in:
Oleg Agafonov 2025-02-06 17:38:09 +04:00
parent b4fa6ace66
commit f17cbbe72b
9 changed files with 349 additions and 50 deletions

View file

@ -4457,11 +4457,7 @@ public abstract class PlayerImpl implements Player, Serializable {
}
/**
* Only used for AIs
*
* @param ability
* @param game
* @return
* AI related code
*/
@Override
public List<Ability> getPlayableOptions(Ability ability, Game game) {
@ -4482,6 +4478,9 @@ public abstract class PlayerImpl implements Player, Serializable {
return options;
}
/**
* AI related code
*/
private void addModeOptions(List<Ability> options, Ability option, Game game) {
// TODO: support modal spells with more than one selectable mode (also must use max modes filter)
for (Mode mode : option.getModes().values()) {
@ -4504,11 +4503,18 @@ public abstract class PlayerImpl implements Player, Serializable {
}
}
/**
* AI related code
*/
protected void addVariableXOptions(List<Ability> options, Ability option, int targetNum, Game game) {
addTargetOptions(options, option, targetNum, game);
}
/**
* AI related code
*/
protected void addTargetOptions(List<Ability> options, Ability option, int targetNum, Game game) {
// TODO: target options calculated for triggered ability too, but do not used in real game
for (Target target : option.getTargets().getUnchosen(game).get(targetNum).getTargetOptions(option, game)) {
Ability newOption = option.copy();
if (target instanceof TargetAmount) {
@ -4521,7 +4527,7 @@ public abstract class PlayerImpl implements Player, Serializable {
newOption.getTargets().get(targetNum).addTarget(targetId, newOption, game, true);
}
}
if (targetNum < option.getTargets().size() - 2) {
if (targetNum < option.getTargets().size() - 2) { // wtf
addTargetOptions(options, newOption, targetNum + 1, game);
} else if (!option.getCosts().getTargets().isEmpty()) {
addCostTargetOptions(options, newOption, 0, game);
@ -4531,6 +4537,9 @@ public abstract class PlayerImpl implements Player, Serializable {
}
}
/**
* AI related code
*/
private void addCostTargetOptions(List<Ability> options, Ability option, int targetNum, Game game) {
for (UUID targetId : option.getCosts().getTargets().get(targetNum).possibleTargets(playerId, option, game)) {
Ability newOption = option.copy();

View file

@ -79,6 +79,9 @@ public interface Target extends Serializable {
boolean isLegal(Ability source, Game game);
/**
* AI related code. Returns all possible different target combinations
*/
List<? extends Target> getTargetOptions(Ability source, Game game);
boolean canChoose(UUID sourceControllerId, Game game);

View file

@ -1,11 +1,16 @@
package mage.target;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.dynamicvalue.common.StaticValue;
import mage.cards.Card;
import mage.constants.Outcome;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.util.DebugUtil;
import mage.util.RandomUtil;
import java.util.*;
import java.util.stream.Collectors;
@ -118,45 +123,229 @@ public abstract class TargetAmount extends TargetImpl {
}
@Override
public List<? extends TargetAmount> getTargetOptions(Ability source, Game game) {
final public List<? extends TargetAmount> getTargetOptions(Ability source, Game game) {
if (!amountWasSet) {
setAmount(source, game);
}
List<TargetAmount> options = new ArrayList<>();
Set<UUID> possibleTargets = possibleTargets(source.getControllerId(), source, game);
addTargets(this, possibleTargets, options, source, game);
// optimizations for less memory/cpu consumptions
printTargetsTableAndVariations("before optimize", game, possibleTargets, options, false);
optimizePossibleTargets(source, game, possibleTargets);
printTargetsTableAndVariations("after optimize", game, possibleTargets, options, false);
// debug target variations
//printTargetsVariations(possibleTargets, options);
// calc possible amount variations
addTargets(this, possibleTargets, options, source, game);
printTargetsTableAndVariations("after calc", game, possibleTargets, options, true);
return options;
}
private void printTargetsVariations(Set<UUID> possibleTargets, List<TargetAmount> options) {
// debug target variations
// permanent index + amount
// example: 7 -> 2; 8 -> 3; 9 -> 1
/**
* AI related, trying to reduce targets for simulations
*/
private void optimizePossibleTargets(Ability source, Game game, Set<UUID> possibleTargets) {
// remove duplicated/same creatures (example: distribute 3 damage between 10+ same tokens)
// it must have additional threshold to keep more variations for analyse
//
// bad example:
// - Blessings of Nature
// - Distribute four +1/+1 counters among any number of target creatures.
// on low targets threshold AI can put 1/1 to opponent's creature instead own, see TargetAmountAITest.test_AI_SimulateTargets
int maxPossibleTargetsToSimulate = this.remainingAmount * 2;
if (possibleTargets.size() < maxPossibleTargetsToSimulate) {
return;
}
// split targets by groups
Map<UUID, String> 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);
} 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:
// - use one target from each target group all the time
// - add random target from random group until fill all remainingAmount condition
// use one target per group
Set<UUID> newPossibleTargets = new HashSet<>();
groups.forEach((groupKey, groupTargets) -> {
UUID targetId = RandomUtil.randomFromCollection(groupTargets);
if (targetId != null) {
newPossibleTargets.add(targetId);
groupTargets.remove(targetId);
}
});
// use random target until fill condition
while (newPossibleTargets.size() < maxPossibleTargetsToSimulate) {
String groupKey = RandomUtil.randomFromCollection(groups.keySet());
if (groupKey == null) {
break;
}
List<UUID> groupTargets = groups.getOrDefault(groupKey, null);
if (groupTargets == null || groupTargets.isEmpty()) {
groups.remove(groupKey);
continue;
}
UUID targetId = RandomUtil.randomFromCollection(groupTargets);
if (targetId != null) {
newPossibleTargets.add(targetId);
groupTargets.remove(targetId);
}
}
// keep final result
possibleTargets.clear();
possibleTargets.addAll(newPossibleTargets);
}
private String getTargetGroupKeyAsPlayer(Player player) {
// use all
return String.join(";", Arrays.asList(
player.getName(),
String.valueOf(player.getId().hashCode())
));
}
private String getTargetGroupKeyAsPermanent(Game game, Permanent permanent) {
// split by name and stats
// TODO: rework and combine with PermanentEvaluator (to use battlefield score)
// try to use short text/hash for lesser data on debug
return String.join(";", Arrays.asList(
permanent.getName(),
String.valueOf(permanent.getControllerId().hashCode()),
String.valueOf(permanent.getOwnerId().hashCode()),
String.valueOf(permanent.isTapped()),
String.valueOf(permanent.getPower().getValue()),
String.valueOf(permanent.getToughness().getValue()),
String.valueOf(permanent.getDamage()),
String.valueOf(permanent.getCardType(game).toString().hashCode()),
String.valueOf(permanent.getSubtype(game).toString().hashCode()),
String.valueOf(permanent.getCounters(game).getTotalCount()),
String.valueOf(permanent.getAbilities(game).size()),
String.valueOf(permanent.getRules(game).toString().hashCode())
));
}
private String getTargetGroupKeyAsCard(Game game, Card card) {
// split by name and stats
return String.join(";", Arrays.asList(
card.getName(),
String.valueOf(card.getOwnerId().hashCode()),
String.valueOf(card.getCardType(game).toString().hashCode()),
String.valueOf(card.getSubtype(game).toString().hashCode()),
String.valueOf(card.getCounters(game).getTotalCount()),
String.valueOf(card.getAbilities(game).size()),
String.valueOf(card.getRules(game).toString().hashCode())
));
}
private String getTargetGroupKeyAsOther(Game game, MageObject item) {
// use all
return String.join(";", Arrays.asList(
item.getName(),
String.valueOf(item.getId().hashCode())
));
}
/**
* Debug only. Print targets table and variations.
*/
private void printTargetsTableAndVariations(String info, Game game, Set<UUID> possibleTargets, List<TargetAmount> options, boolean isPrintOptions) {
if (!DebugUtil.AI_SHOW_TARGET_OPTIMIZATION_LOGS) return;
// output example:
//
// Targets (after optimize): 5
// 0. Balduvian Bears [ac8], C, BalduvianBears, DKM:22::0, 2/2
// 1. PlayerA (SimulatedPlayer2)
//
// Target variations (info): 126
// 0 -> 1; 1 -> 1; 2 -> 1; 3 -> 1; 4 -> 1
// 0 -> 1; 1 -> 1; 2 -> 1; 3 -> 2
// 0 -> 1; 1 -> 1; 2 -> 1; 4 -> 2
// print table
List<UUID> list = new ArrayList<>(possibleTargets);
Collections.sort(list);
HashMap<UUID, Integer> targetNumbers = new HashMap<>();
System.out.println();
System.out.println(String.format("Targets (%s): %d", info, list.size()));
for (int i = 0; i < list.size(); i++) {
targetNumbers.put(list.get(i), i);
String targetName;
Player player = game.getPlayer(list.get(i));
if (player != null) {
targetName = player.toString();
} else {
MageObject object = game.getObject(list.get(i));
if (object != null) {
targetName = object.toString();
} else {
targetName = "unknown";
}
}
System.out.println(String.format("%d. %s", i, targetName));
}
System.out.println();
if (!isPrintOptions) {
return;
}
// print amount variations
List<String> res = options
.stream()
.map(t -> t.getTargets()
.stream()
.map(id -> targetNumbers.get(id) + " -> " + t.getTargetAmount(id))
.sorted()
.collect(Collectors.joining("; ")))
.collect(Collectors.toList());
Collections.sort(res);
.collect(Collectors.joining("; "))).sorted().collect(Collectors.toList());
System.out.println();
System.out.println(res.stream().collect(Collectors.joining("\n")));
System.out.println(String.format("Target variations (info): %d", options.size()));
System.out.println(String.join("\n", res));
System.out.println();
}
protected void addTargets(TargetAmount target, Set<UUID> possibleTargets, List<TargetAmount> options, Ability source, Game game) {
if (!amountWasSet) {
setAmount(source, game);
}
final protected void addTargets(TargetAmount target, Set<UUID> possibleTargets, List<TargetAmount> options, Ability source, Game game) {
Set<UUID> usedTargets = new HashSet<>();
for (UUID targetId : possibleTargets) {
usedTargets.add(targetId);

View file

@ -446,13 +446,6 @@ public abstract class TargetImpl implements Target {
return !targets.isEmpty();
}
/**
* Returns all possible different target combinations
*
* @param source
* @param game
* @return
*/
@Override
public List<? extends TargetImpl> getTargetOptions(Ability source, Game game) {
List<TargetImpl> options = new ArrayList<>();

View file

@ -11,6 +11,12 @@ public class DebugUtil {
public static boolean NETWORK_SHOW_CLIENT_CALLBACK_MESSAGES_LOG = false; // show all callback messages (server commands)
// AI
// game simulations runs in multiple threads, if you stop code to debug then it will be terminated by timeout
// so AI debug mode will make single simulation thread without any timeouts
public static boolean AI_ENABLE_DEBUG_MODE = false;
public static boolean AI_SHOW_TARGET_OPTIMIZATION_LOGS = false; // works with target amount
// cards basic (card panels)
public static boolean GUI_CARD_DRAW_OUTER_BORDER = false;
public static boolean GUI_CARD_DRAW_INNER_BORDER = false;