From 2833460e593bd4cf61aba8bb8a5ef4d1c07ed75b Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Sun, 10 Aug 2025 02:16:18 +0400 Subject: [PATCH] AI: improved performance on too many possible targets (fix game freezes and server crashes - see #9539, #9438, #9518, related to #11285, #5023); --- .../src/mage/player/ai/ComputerPlayer6.java | 13 +- .../src/mage/player/ai/SimulatedPlayer2.java | 5 +- .../AI/basic/SimulationPerformanceAITest.java | 32 ++- .../AI/basic/SimulationTriggersAITest.java | 84 +++++++ .../main/java/mage/players/PlayerImpl.java | 6 +- Mage/src/main/java/mage/target/Target.java | 4 +- .../main/java/mage/target/TargetAmount.java | 220 +--------------- .../src/main/java/mage/target/TargetImpl.java | 28 ++- .../java/mage/target/TargetOptimization.java | 237 ++++++++++++++++++ Mage/src/main/java/mage/util/DebugUtil.java | 2 +- 10 files changed, 397 insertions(+), 234 deletions(-) create mode 100644 Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationTriggersAITest.java create mode 100644 Mage/src/main/java/mage/target/TargetOptimization.java diff --git a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java index 33f70ceb3c6..5ab8241d41c 100644 --- a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java +++ b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java @@ -219,6 +219,7 @@ public class ComputerPlayer6 extends ComputerPlayer { } // Condition to stop deeper simulation if (SimulationNode2.nodeCount > MAX_SIMULATED_NODES_PER_ERROR) { + // how-to fix: make sure you are disabled debug mode by COMPUTER_DISABLE_TIMEOUT_IN_GAME_SIMULATIONS = false throw new IllegalStateException("AI ERROR: too much nodes (possible actions)"); } if (depth <= 0 @@ -501,7 +502,7 @@ public class ComputerPlayer6 extends ComputerPlayer { } logger.warn("Possible freeze chain:"); if (root != null && chain.isEmpty()) { - logger.warn(" - unknown use case"); // maybe can't finish any calc, maybe related to target options, I don't know + logger.warn(" - unknown use case (too many possible targets?)"); // maybe can't finish any calc, maybe related to target options, I don't know } chain.forEach(s -> { logger.warn(" - " + s); @@ -642,7 +643,7 @@ public class ComputerPlayer6 extends ComputerPlayer { return "unknown"; }) .collect(Collectors.joining(", ")); - logger.info(String.format("Sim Prio [%d] -> with choices (TODO): [%d] (%s)", + logger.info(String.format("Sim Prio [%d] -> with possible choices: [%d] (%s)", depth, currentNode.getDepth(), printDiffScore(currentScore - prevScore), @@ -651,14 +652,18 @@ public class ComputerPlayer6 extends ComputerPlayer { } else if (!currentNode.getChoices().isEmpty()) { // ON CHOICES String choicesInfo = String.join(", ", currentNode.getChoices()); - logger.info(String.format("Sim Prio [%d] -> with choices (TODO): [%d] (%s)", + logger.info(String.format("Sim Prio [%d] -> with possible choices (must not see that code): [%d] (%s)", depth, currentNode.getDepth(), printDiffScore(currentScore - prevScore), choicesInfo) ); } else { - throw new IllegalStateException("AI CALC ERROR: unknown calculation result (no abilities, no targets, no choices)"); + logger.info(String.format("Sim Prio [%d] -> with do nothing: [%d]", + depth, + currentNode.getDepth(), + printDiffScore(currentScore - prevScore)) + ); } } } diff --git a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java index d787cd2f5a2..9d7347f7d2b 100644 --- a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java +++ b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java @@ -318,6 +318,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer { Ability ability = source.copy(); List options = getPlayableOptions(ability, game); if (options.isEmpty()) { + // no options - activate as is logger.debug("simulating -- triggered ability:" + ability); game.getStack().push(game, new StackAbility(ability, playerId)); if (ability.activate(game, false) && ability.isUsesStack()) { @@ -326,6 +327,8 @@ public final class SimulatedPlayer2 extends ComputerPlayer { game.applyEffects(); game.getPlayers().resetPassed(); } else { + // many options - activate and add to sims tree + // TODO: AI run all sims, but do not use best option for triggers yet SimulationNode2 parent = (SimulationNode2) game.getCustomData(); int depth = parent.getDepth() - 1; if (depth == 0) { @@ -350,7 +353,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer { logger.debug("simulating -- node #:" + SimulationNode2.getCount() + " triggered ability option"); for (Target target : ability.getTargets()) { for (UUID targetId : target.getTargets()) { - newNode.getTargets().add(targetId); // save for info only (real targets in newNode.ability already) + newNode.getTargets().add(targetId); // save for info only (real targets in newNode.game.stack already) } } parent.children.add(newNode); diff --git a/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationPerformanceAITest.java b/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationPerformanceAITest.java index c8ce253a707..78297b0e3b9 100644 --- a/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationPerformanceAITest.java +++ b/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationPerformanceAITest.java @@ -6,7 +6,6 @@ import mage.constants.PhaseStep; import mage.constants.Zone; import mage.game.permanent.Permanent; import org.junit.Assert; -import org.junit.Ignore; import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBaseAI; @@ -21,7 +20,6 @@ import java.util.List; * TODO: add tests and implement best choice selection on timeout * (AI must make any good/bad choice on timeout with game log - not a skip) *

- * TODO: AI do not support game simulations for target options in triggered * * @author JayDi85 */ @@ -106,28 +104,24 @@ public class SimulationPerformanceAITest extends CardTestPlayerBaseAI { } @Test - @Ignore // enable after triggered supported or need performance test public void test_ManyTargetOptions_Triggered_Single() { // 2 damage to bear and 3 damage to player B runManyTargetOptionsInTrigger("1 target creature", 1, 1, false, 20 - 3); } @Test - @Ignore // enable after triggered supported or need performance test public void test_ManyTargetOptions_Triggered_Few() { // 4 damage to x2 bears and 1 damage to player B runManyTargetOptionsInTrigger("2 target creatures", 2, 2, false, 20 - 1); } @Test - @Ignore // enable after triggered supported or need performance test public void test_ManyTargetOptions_Triggered_Many() { // 4 damage to x2 bears and 1 damage to player B runManyTargetOptionsInTrigger("5 target creatures", 5, 2, false, 20 - 1); } @Test - @Ignore // enable after triggered supported or need performance test public void test_ManyTargetOptions_Triggered_TooMuch() { // warning, can be slow @@ -139,7 +133,6 @@ public class SimulationPerformanceAITest extends CardTestPlayerBaseAI { } @Test - @Ignore // enable after triggered supported or need performance test public void test_ManyTargetOptions_Triggered_TargetGroups() { // make sure targets optimization can find unique creatures, e.g. damaged @@ -213,4 +206,29 @@ public class SimulationPerformanceAITest extends CardTestPlayerBaseAI { // 4 damage to x2 bears and 1 damage to damaged bear runManyTargetOptionsInActivate("5 target creatures with one damaged", 5, 3, true, 20); } + + @Test + public void test_ElderDeepFiend_TooManyUpToChoices() { + // bug: game freeze with 100% CPU usage + // https://github.com/magefree/mage/issues/9518 + int cardsCount = 2; // 2+ cards will generate too much target options for simulations + + // Boulderfall deals 5 damage divided as you choose among any number of targets. + // Flash + // Emerge {5}{U}{U} (You may cast this spell by sacrificing a creature and paying the emerge cost reduced by that creature's mana value.) + // When you cast this spell, tap up to four target permanents. + addCard(Zone.HAND, playerA, "Elder Deep-Fiend", cardsCount); // {8} + addCard(Zone.BATTLEFIELD, playerA, "Island", 8 * cardsCount); + // + addCard(Zone.BATTLEFIELD, playerB, "Balduvian Bears", 2); + addCard(Zone.BATTLEFIELD, playerB, "Kitesail Corsair", 2); + addCard(Zone.BATTLEFIELD, playerB, "Alpha Tyrranax", 2); + addCard(Zone.BATTLEFIELD, playerB, "Abbey Griffin", 2); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, "Elder Deep-Fiend", cardsCount); // ai must cast it + } } diff --git a/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationTriggersAITest.java b/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationTriggersAITest.java new file mode 100644 index 00000000000..a13f26171be --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/AI/basic/SimulationTriggersAITest.java @@ -0,0 +1,84 @@ +package org.mage.test.AI.basic; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import mage.counters.CounterType; +import org.junit.Ignore; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBaseWithAIHelps; + +/** + * Make sure AI can simulate priority with triggers resolve + * + * @author JayDi85 + */ +public class SimulationTriggersAITest extends CardTestPlayerBaseWithAIHelps { + + @Test + @Ignore + // TODO: trigger's target options supported on priority sim, but do not used for some reason + // see addTargetOptions, node.children, ComputerPlayer6->targets, etc + public void test_DeepglowSkate_MustBeSimulated() { + // make sure targets choosing on trigger use same game sims and best results + + // When Deepglow Skate enters the battlefield, double the number of each kind of counter on any number + // of target permanents. + addCard(Zone.HAND, playerA, "Deepglow Skate", 1); + addCard(Zone.BATTLEFIELD, playerA, "Island", 5); + // + addCard(Zone.BATTLEFIELD, playerA, "Ajani, Adversary of Tyrants", 1); // x4 loyalty + addCard(Zone.BATTLEFIELD, playerA, "Ajani, Caller of the Pride", 1); // x4 loyalty + // + // This creature enters with a -1/-1 counter on it. + addCard(Zone.BATTLEFIELD, playerA, "Bloodied Ghost", 1); // 3/3 + // + // Players can't activate planeswalkers' loyalty abilities. + addCard(Zone.BATTLEFIELD, playerA, "The Immortal Sun", 1); // disable planeswalkers usage by AI + + // AI must cast boost and ignore doubling of -1/-1 counters on own creatures due bad score + aiPlayStep(1, PhaseStep.PRECOMBAT_MAIN, playerA); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, "Deepglow Skate", 1); + assertCounterCount(playerA, "Ajani, Adversary of Tyrants", CounterType.LOYALTY, 4 * 2); + assertCounterCount(playerA, "Ajani, Caller of the Pride", CounterType.LOYALTY, 4 * 2); + assertCounterCount(playerA, "Bloodied Ghost", CounterType.M1M1, 1); // make sure AI will not double bad counters + } + + @Test + public void test_DeepglowSkate_PerformanceOnTooManyChoices() { + // bug: game freeze with 100% CPU usage + // https://github.com/magefree/mage/issues/9438 + int cardsCount = 2; // 2+ cards will generate too much target options for simulations + int boostMultiplier = (int) Math.pow(2, cardsCount); + + // When Deepglow Skate enters the battlefield, double the number of each kind of counter on any number + // of target permanents. + addCard(Zone.HAND, playerA, "Deepglow Skate", cardsCount); // {4}{U} + addCard(Zone.BATTLEFIELD, playerA, "Island", 5 * cardsCount); + // + 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, playerB, "Ajani Goldmane", 1); // x4 loyalty + addCard(Zone.BATTLEFIELD, playerB, "Ajani, Inspiring Leader", 1); // x5 loyalty + // + // Players can't activate planeswalkers' loyalty abilities. + addCard(Zone.BATTLEFIELD, playerA, "The Immortal Sun", 1); // disable planeswalkers usage by AI + + // AI must cast multiple booster spells and double only own counters and only good + aiPlayStep(1, PhaseStep.PRECOMBAT_MAIN, playerA); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, "Deepglow Skate", cardsCount); + assertCounterCount(playerA, "Ajani, Adversary of Tyrants", CounterType.LOYALTY, 4 * boostMultiplier); + assertCounterCount(playerA, "Ajani, Caller of the Pride", CounterType.LOYALTY, 4 * boostMultiplier); + assertCounterCount(playerB, "Ajani Goldmane", CounterType.LOYALTY, 4); + assertCounterCount(playerB, "Ajani, Inspiring Leader", CounterType.LOYALTY, 5); + } +} diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index e35f1d43034..3ea064bccf2 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -4524,7 +4524,6 @@ public abstract class PlayerImpl implements Player, Serializable { } else if (ability.getCosts().getTargets().getNextUnchosen(game) != null) { addCostTargetOptions(options, ability, 0, game); } - return options; } @@ -4565,9 +4564,6 @@ public abstract class PlayerImpl implements Player, Serializable { */ protected void addTargetOptions(List options, Ability option, int targetNum, Game game) { // TODO: target options calculated for triggered ability too, but do not used in real game - // TODO: there are rare errors with wrong targetNum - maybe multiple game sims can change same target object somehow? - // do not hide NullPointError here, research instead - if (targetNum >= option.getTargets().size()) { return; } @@ -4592,6 +4588,8 @@ public abstract class PlayerImpl implements Player, Serializable { newOption.getTargets().get(targetNum).addTarget(targetId, newOption, game, true); } } + // don't forget about target's status (if it zero then must set skip choice too) + newOption.getTargets().get(targetNum).setSkipChoice(targetOption.isSkipChoice()); if (targetNum + 1 < option.getTargets().size()) { // fill more targets diff --git a/Mage/src/main/java/mage/target/Target.java b/Mage/src/main/java/mage/target/Target.java index 89b9b113f04..e8c17ff9c36 100644 --- a/Mage/src/main/java/mage/target/Target.java +++ b/Mage/src/main/java/mage/target/Target.java @@ -41,8 +41,8 @@ public interface Target extends Copyable, Serializable { boolean isChoiceCompleted(UUID abilityControllerId, Ability source, Game game, Cards fromCards); /** - * Temporary status to work with "up to" targets (mark target that it was skip on selection) - * TODO: remove after target.chooseXXX remove + * Tests and AI related for "up to" targets (mark target that it was skipped on selection, so new choose dialog will be called) + * Example: AI sim possible target options */ boolean isSkipChoice(); diff --git a/Mage/src/main/java/mage/target/TargetAmount.java b/Mage/src/main/java/mage/target/TargetAmount.java index bcc4b84bc4e..9fcba18dee9 100644 --- a/Mage/src/main/java/mage/target/TargetAmount.java +++ b/Mage/src/main/java/mage/target/TargetAmount.java @@ -1,17 +1,13 @@ 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.cards.Cards; 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 mage.util.CardUtil; import java.util.*; import java.util.stream.Collectors; @@ -19,7 +15,7 @@ import java.util.stream.Collectors; /** * Distribute value between targets list (damage, counters, etc) * - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public abstract class TargetAmount extends TargetImpl { @@ -208,217 +204,21 @@ public abstract class TargetAmount extends TargetImpl { Set possibleTargets = possibleTargets(source.getControllerId(), 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); - - // calc possible amount variations - addTargets(this, possibleTargets, options, source, game); - printTargetsTableAndVariations("after calc", game, possibleTargets, options, true); - - return options; - } - - /** - * AI related, trying to reduce targets for simulations - */ - private void optimizePossibleTargets(Ability source, Game game, Set possibleTargets) { - // remove duplicated/same creatures (example: distribute 3 damage between 10+ same tokens) - + TargetOptimization.printTargetsVariationsForTargetAmount("target amount - before optimize", game, possibleTargets, options, false); // 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 = CardUtil.overflowMultiply(this.remainingAmount, 2); + TargetOptimization.optimizePossibleTargets(source, game, possibleTargets, maxPossibleTargetsToSimulate); + TargetOptimization.printTargetsVariationsForTargetAmount("target amount - after optimize", game, possibleTargets, options, false); - int maxPossibleTargetsToSimulate = this.remainingAmount * 2; - if (possibleTargets.size() < maxPossibleTargetsToSimulate) { - return; - } + // calc possible amount variations + addTargets(this, possibleTargets, options, source, game); + TargetOptimization.printTargetsVariationsForTargetAmount("target amount - after calc", game, possibleTargets, options, true); - // split targets by groups - Map 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> 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 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 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 possibleTargets, List 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 list = new ArrayList<>(possibleTargets); - Collections.sort(list); - HashMap 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 res = options - .stream() - .map(t -> t.getTargets() - .stream() - .map(id -> targetNumbers.get(id) + " -> " + t.getTargetAmount(id)) - .sorted() - .collect(Collectors.joining("; "))).sorted().collect(Collectors.toList()); - System.out.println(); - System.out.println(String.format("Target variations (info): %d", options.size())); - System.out.println(String.join("\n", res)); - System.out.println(); + return options; } final protected void addTargets(TargetAmount target, Set possibleTargets, List options, Ability source, Game game) { diff --git a/Mage/src/main/java/mage/target/TargetImpl.java b/Mage/src/main/java/mage/target/TargetImpl.java index 1fed07fc4a4..e0a00773cc2 100644 --- a/Mage/src/main/java/mage/target/TargetImpl.java +++ b/Mage/src/main/java/mage/target/TargetImpl.java @@ -19,7 +19,7 @@ import mage.util.RandomUtil; import java.util.*; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public abstract class TargetImpl implements Target { @@ -321,7 +321,7 @@ public abstract class TargetImpl implements Target { @Override public boolean isChoiceSelected() { // min = max = 0 - for abilities with X=0, e.g. nothing to choose - return chosen || getMaxNumberOfTargets() == 0 && getMinNumberOfTargets() == 0; + return chosen || getMaxNumberOfTargets() == 0 && getMinNumberOfTargets() == 0 || isSkipChoice(); } @Override @@ -586,11 +586,24 @@ public abstract class TargetImpl implements Target { @Override public List getTargetOptions(Ability source, Game game) { List options = new ArrayList<>(); - List possibleTargets = new ArrayList<>(possibleTargets(source.getControllerId(), source, game)); + Set possibleTargets = possibleTargets(source.getControllerId(), source, game); + + // optimizations for less memory/cpu consumptions + int maxPossibleTargetsToSimulate = Math.min(TargetOptimization.AI_MAX_POSSIBLE_TARGETS_TO_CHOOSE, possibleTargets.size()); // see TargetAmount + if (getMinNumberOfTargets() > 0) { + maxPossibleTargetsToSimulate = Math.max(maxPossibleTargetsToSimulate, getMinNumberOfTargets()); + } + TargetOptimization.printTargetsVariationsForTarget("target - before optimize", game, possibleTargets, options, false); + TargetOptimization.optimizePossibleTargets(source, game, possibleTargets, maxPossibleTargetsToSimulate); + TargetOptimization.printTargetsVariationsForTarget("target - after optimize", game, possibleTargets, options, false); + + // calc all optimized combinations + // TODO: replace by google/apache lib to generate all combinations + List needPossibleTargets = new ArrayList<>(possibleTargets); // get the length of the array // e.g. for {'A','B','C','D'} => N = 4 - int N = possibleTargets.size(); + int N = needPossibleTargets.size(); // not enough targets, return no option if (N < getMinNumberOfTargets()) { return options; @@ -598,6 +611,7 @@ public abstract class TargetImpl implements Target { // not target but that's allowed, return one empty option if (N == 0) { TargetImpl target = this.copy(); + target.setSkipChoice(true); options.add(target); return options; } @@ -617,6 +631,7 @@ public abstract class TargetImpl implements Target { int minK = getMinNumberOfTargets(); if (getMinNumberOfTargets() == 0) { // add option without targets if possible TargetImpl target = this.copy(); + target.setSkipChoice(true); options.add(target); minK = 1; } @@ -645,7 +660,7 @@ public abstract class TargetImpl implements Target { //add the new target option TargetImpl target = this.copy(); for (int i = 0; i < combination.length; i++) { - target.addTarget(possibleTargets.get(combination[i]), source, game, true); + target.addTarget(needPossibleTargets.get(combination[i]), source, game, true); } options.add(target); index++; @@ -664,6 +679,9 @@ public abstract class TargetImpl implements Target { } } } + + TargetOptimization.printTargetsVariationsForTarget("target - after calc", game, possibleTargets, options, true); + return options; } diff --git a/Mage/src/main/java/mage/target/TargetOptimization.java b/Mage/src/main/java/mage/target/TargetOptimization.java new file mode 100644 index 00000000000..879b32e5f57 --- /dev/null +++ b/Mage/src/main/java/mage/target/TargetOptimization.java @@ -0,0 +1,237 @@ +package mage.target; + +import mage.MageObject; +import mage.abilities.Ability; +import mage.cards.Card; +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; + +/** + * Helper class to optimize possible targets list for AI and playable calcs + *

+ * Features: + * - less possible targets, less combinations, less CPU/memory usage for sims + * - group all possible targets by same characteristics; + * - fill target one by one from each group + * + * @author JayDi85 + */ +public class TargetOptimization { + + // for up to or any amount - limit max game sims to analyse + // (it's useless to calc all possible combinations on too much targets) + static public int AI_MAX_POSSIBLE_TARGETS_TO_CHOOSE = 7; + + public static void optimizePossibleTargets(Ability source, Game game, Set possibleTargets, int maxPossibleTargetsToSimulate) { + // remove duplicated/same creatures + // example: distribute 3 damage between 10+ same tokens + // example: target x1 from x10 forests - it's useless to recalc each forest + + if (possibleTargets.size() < maxPossibleTargetsToSimulate) { + return; + } + + // split targets by groups + Map 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> 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 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 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 static String getTargetGroupKeyAsPlayer(Player player) { + // use all + return String.join(";", Arrays.asList( + player.getName(), + String.valueOf(player.getId().hashCode()) + )); + } + + private static 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 static 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 static String getTargetGroupKeyAsOther(Game game, MageObject item) { + // use all + return String.join(";", Arrays.asList( + item.getName(), + String.valueOf(item.getId().hashCode()) + )); + } + + public static void printTargetsVariationsForTarget(String info, Game game, Set possibleTargets, List options, boolean isPrintOptions) { + List usedOptions = options.stream() + .filter(Objects::nonNull) + .map(TargetImpl.class::cast) + .collect(Collectors.toList()); + printTargetsTableAndVariationsInner(info, game, possibleTargets, usedOptions, isPrintOptions); + } + + public static void printTargetsVariationsForTargetAmount(String info, Game game, Set possibleTargets, List options, boolean isPrintOptions) { + List usedOptions = options.stream() + .filter(Objects::nonNull) + .map(TargetImpl.class::cast) + .collect(Collectors.toList()); + printTargetsTableAndVariationsInner(info, game, possibleTargets, usedOptions, isPrintOptions); + } + + private static void printTargetsTableAndVariationsInner(String info, Game game, Set possibleTargets, List 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 list = new ArrayList<>(possibleTargets); + Collections.sort(list); + HashMap 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 res = options + .stream() + .map(t -> t.getTargets() + .stream() + .map(id -> targetNumbers.get(id) + (t instanceof TargetAmount ? " -> " + t.getTargetAmount(id) : "")) + .sorted() + .collect(Collectors.joining("; "))).sorted().collect(Collectors.toList()); + System.out.println(); + System.out.println(String.format("Target variations (info): %d", options.size())); + System.out.println(String.join("\n", res)); + System.out.println(); + } + +} diff --git a/Mage/src/main/java/mage/util/DebugUtil.java b/Mage/src/main/java/mage/util/DebugUtil.java index fcad86ea034..8706f5f2ffb 100644 --- a/Mage/src/main/java/mage/util/DebugUtil.java +++ b/Mage/src/main/java/mage/util/DebugUtil.java @@ -15,7 +15,7 @@ public class DebugUtil { // 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 + public static boolean AI_SHOW_TARGET_OPTIMIZATION_LOGS = false; // works with target and target amount calculations // SERVER // data collectors - enable additional logs and data collection for better AI and human games debugging