From 328922029a15d72a8800b456478c730c021e8407 Mon Sep 17 00:00:00 2001 From: Myrd Date: Sun, 25 Dec 2016 05:05:18 +0000 Subject: [PATCH] [Simulated AI] Refactor code to create a Plan object. This allows coming up with a multi-step planning and caching it, so it doesn't need to be re-computed at subsequent steps if nothing meaningful changed. --- .gitattributes | 1 + .../forge/ai/simulation/GameSimulator.java | 2 +- .../ai/simulation/GameStateEvaluator.java | 5 +- .../main/java/forge/ai/simulation/Plan.java | 78 ++++++++++ .../ai/simulation/PossibleTargetSelector.java | 51 ++++++- .../ai/simulation/SimulationController.java | 81 +++++++++- .../ai/simulation/SpellAbilityPicker.java | 144 ++++++++++++++---- .../ai/simulation/GameSimulatorTest.java | 7 +- 8 files changed, 321 insertions(+), 48 deletions(-) create mode 100644 forge-ai/src/main/java/forge/ai/simulation/Plan.java diff --git a/.gitattributes b/.gitattributes index 3370d46a2a1..d191902d9e4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -156,6 +156,7 @@ forge-ai/src/main/java/forge/ai/ability/ZoneExchangeAi.java -text forge-ai/src/main/java/forge/ai/simulation/GameCopier.java -text forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java -text forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java -text +forge-ai/src/main/java/forge/ai/simulation/Plan.java -text forge-ai/src/main/java/forge/ai/simulation/PossibleTargetSelector.java -text forge-ai/src/main/java/forge/ai/simulation/SimulationController.java -text forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java -text diff --git a/forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java b/forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java index 2dfc7de335f..979287a7a89 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java +++ b/forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java @@ -192,7 +192,7 @@ public class GameSimulator { } controller.printState(score, origSa); if (controller.shouldRecurse() && !simGame.isGameOver()) { - controller.push(sa); + controller.push(sa, score); SpellAbilityPicker sim = new SpellAbilityPicker(simGame, aiPlayer); CardCollection cards = ComputerUtilAbility.getAvailableCards(simGame, aiPlayer); List all = ComputerUtilAbility.getSpellAbilities(cards, aiPlayer); diff --git a/forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java b/forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java index 9931d9670a0..68ce7758a4d 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java +++ b/forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java @@ -79,9 +79,9 @@ public class GameStateEvaluator { for (Card c : game.getCardsIn(ZoneType.Battlefield)) { int value = evalCard(game, aiPlayer, c, combat); int summonSickValue = value; - // To make the AI hold-off on playing creatures in MAIN1 if they give no other benefits, + // To make the AI hold-off on playing creatures before MAIN2 if they give no other benefits, // keep track of the score while treating summon sick creatures as having a value of 0. - if (gamePhase == PhaseType.MAIN1 && c.isSick() && c.getController() == aiPlayer) { + if (gamePhase.isBefore(PhaseType.MAIN2) && c.isSick() && c.getController() == aiPlayer) { summonSickValue = 0; } String str = c.getName(); @@ -167,7 +167,6 @@ public class GameStateEvaluator { public static class Score { public final int value; public final int summonSickValue; - public String choice; public Score(int value) { this.value = value; diff --git a/forge-ai/src/main/java/forge/ai/simulation/Plan.java b/forge-ai/src/main/java/forge/ai/simulation/Plan.java new file mode 100644 index 00000000000..cb77a5e7333 --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/simulation/Plan.java @@ -0,0 +1,78 @@ +package forge.ai.simulation; + +import java.util.ArrayList; +import java.util.List; + +import forge.ai.simulation.GameStateEvaluator.Score; +import forge.game.card.Card; +import forge.game.spellability.SpellAbility; + +public class Plan { + private List decisions; + private int nextDecisionIndex; + private Decision selectedDecision; + + public Plan(ArrayList decisions) { + this.decisions = decisions; + } + + public List getDecisions() { + return decisions; + } + + public boolean hasNextDecision() { + return nextDecisionIndex < decisions.size(); + } + + public Decision selectNextDecision() { + selectedDecision = decisions.get(nextDecisionIndex); + nextDecisionIndex++; + return selectedDecision; + } + + public Decision getSelectedDecision() { + return selectedDecision; + } + + public int getNextDecisionIndex() { + return nextDecisionIndex; + } + + public static class Decision { + final Decision prevDecision; + final Score initialScore; + + final String sa; + PossibleTargetSelector.Targets targets; + String choice; + + public Decision(Score initialScore, Decision prevDecision, SpellAbility sa) { + this.initialScore = initialScore; + this.prevDecision = prevDecision; + this.sa = sa.toString(); + this.targets = null; + this.choice = null; + } + + public Decision(Score initialScore, Decision prevDecision, PossibleTargetSelector.Targets targets) { + this.initialScore = initialScore; + this.prevDecision = prevDecision; + this.sa = null; + this.targets = targets; + this.choice = null; + } + + public Decision(Score initialScore, Decision prevDecision, Card choice) { + this.initialScore = initialScore; + this.prevDecision = prevDecision; + this.sa = null; + this.targets = null; + this.choice = choice.getName(); + } + + @Override + public String toString() { + return "[initScore=" + initialScore + " " + sa + " " + targets + " " + choice + "]"; + } + } +} diff --git a/forge-ai/src/main/java/forge/ai/simulation/PossibleTargetSelector.java b/forge-ai/src/main/java/forge/ai/simulation/PossibleTargetSelector.java index 86a85720853..c8012555f30 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/PossibleTargetSelector.java +++ b/forge-ai/src/main/java/forge/ai/simulation/PossibleTargetSelector.java @@ -16,23 +16,43 @@ public class PossibleTargetSelector { private int targetIndex; private List validTargets; + public static class Targets { + final int originalTargetCount; + final int targetIndex; + final String description; + + private Targets(int originalTargetCount, int targetIndex, String description) { + this.originalTargetCount = originalTargetCount; + this.targetIndex = targetIndex; + this.description = description; + + if (targetIndex < 0 || targetIndex >= originalTargetCount) { + throw new IllegalArgumentException("Invalid targetIndex=" + targetIndex); + } + } + + @Override + public String toString() { + return description; + } + } + public PossibleTargetSelector(Game game, Player self, SpellAbility sa) { this.sa = sa; this.tgt = sa.getTargetRestrictions(); this.targetIndex = 0; this.validTargets = new ArrayList(); + sa.resetTargets(); sa.setActivatingPlayer(self); for (GameObject o : tgt.getAllCandidates(sa, true)) { validTargets.add(o); } } - - public boolean selectNextTargets() { - if (targetIndex >= validTargets.size()) { - return false; - } + + private void selectTargetsByIndex(int index) { sa.resetTargets(); - int index = targetIndex; + + // TODO: smarter about multiple targets, identical targets, etc... while (sa.getTargets().getNumTargeted() < tgt.getMaxTargets(sa.getHostCard(), sa) && index < validTargets.size()) { sa.getTargets().add(validTargets.get(index++)); } @@ -53,8 +73,25 @@ public class PossibleTargetSelector { } } } + } - // TODO: smarter about multiple targets, identical targets, etc... + public Targets getLastSelectedTargets() { + return new Targets(validTargets.size(), targetIndex - 1, sa.getTargets().getTargetedString()); + } + + public boolean selectTargets(Targets targets) { + if (targets.originalTargetCount != validTargets.size()) { + return false; + } + selectTargetsByIndex(targets.targetIndex); + return true; + } + + public boolean selectNextTargets() { + if (targetIndex >= validTargets.size()) { + return false; + } + selectTargetsByIndex(targetIndex); targetIndex++; return true; } diff --git a/forge-ai/src/main/java/forge/ai/simulation/SimulationController.java b/forge-ai/src/main/java/forge/ai/simulation/SimulationController.java index 320cb279fb0..98e83fbb2fc 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/SimulationController.java +++ b/forge-ai/src/main/java/forge/ai/simulation/SimulationController.java @@ -1,28 +1,105 @@ package forge.ai.simulation; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import forge.ai.simulation.GameStateEvaluator.Score; +import forge.game.card.Card; import forge.game.spellability.SpellAbility; public class SimulationController { private static int MAX_DEPTH = 2; private int recursionDepth; + + private List currentStack; + private List scoreStack; + private Plan.Decision bestSequence; // last action of sequence + private Score bestScore; - public SimulationController() { + public SimulationController(Score score) { + bestScore = score; + scoreStack = new ArrayList(); + scoreStack.add(score); + currentStack = new ArrayList(); } public boolean shouldRecurse() { return recursionDepth < MAX_DEPTH; } - public void push(SpellAbility sa) { + private Plan.Decision getLastDecision() { + if (currentStack.isEmpty()) { + return null; + } + return currentStack.get(currentStack.size() - 1); + } + + private Score getCurrentScore() { + return scoreStack.get(scoreStack.size() - 1); + } + + public void evaluateSpellAbility(SpellAbility sa) { + currentStack.add(new Plan.Decision(getCurrentScore(), getLastDecision(), sa)); + } + + public void evaluateCardChoice(Card choice) { + currentStack.add(new Plan.Decision(getCurrentScore(), getLastDecision(), choice)); + } + + public void evaluateTargetChoices(PossibleTargetSelector.Targets targets) { + currentStack.add(new Plan.Decision(getCurrentScore(), getLastDecision(), targets)); + } + + public void doneEvaluating(Score score) { + if (score.value > bestScore.value) { + bestScore = score; + bestSequence = currentStack.get(currentStack.size() - 1); + } + currentStack.remove(currentStack.size() - 1); + } + + public Score getBestScore() { + return bestScore; + } + + public Plan getBestPlan() { + ArrayList sequence = new ArrayList(); + Plan.Decision current = bestSequence; + while (current != null) { + sequence.add(current); + current = current.prevDecision; + } + Collections.reverse(sequence); + // Merge targets & choices into their parents. + int writeIndex = 0; + for (int i = 0; i < sequence.size(); i++) { + Plan.Decision d = sequence.get(i); + System.out.println("SeqInput: " + d); + if (d.sa != null) { + sequence.set(writeIndex, d); + writeIndex++; + } else if (d.targets != null) { + sequence.get(writeIndex - 1).targets = d.targets; + } else if (d.choice != null) { + sequence.get(writeIndex - 1).choice = d.choice; + } + } + sequence.subList(writeIndex, sequence.size()).clear(); + return new Plan(sequence); + } + + public void push(SpellAbility sa, Score score) { GameSimulator.debugPrint("Recursing DEPTH=" + recursionDepth); GameSimulator.debugPrint(" With: " + sa); recursionDepth++; + scoreStack.add(score); } public void pop(Score score, SpellAbility nextSa) { recursionDepth--; + scoreStack.remove(scoreStack.size() - 1); GameSimulator.debugPrint("DEPTH"+recursionDepth+" best score " + score + " " + nextSa); } diff --git a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java index badb9e5ad9e..1ed4375cccc 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java +++ b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java @@ -1,6 +1,5 @@ package forge.ai.simulation; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -25,7 +24,9 @@ public class SpellAbilityPicker { private Score bestScore; private boolean printOutput; private Interceptor interceptor; - + + private Plan plan; + public SpellAbilityPicker(Game game, Player player) { this.game = game; this.player = player; @@ -40,22 +41,20 @@ public class SpellAbilityPicker { System.out.println(str); } } - - public SpellAbility chooseSpellAbilityToPlay(SimulationController controller, final List all, boolean skipCounter) { - printOutput = false; - if (controller == null) { - controller = new SimulationController(); - printOutput = true; - } + + private void printPhaseInfo() { String phaseStr = game.getPhaseHandler().getPhase().toString(); if (game.getPhaseHandler().getPlayerTurn() != player) { phaseStr = "opponent " + phaseStr; } print("---- choose ability (phase = " + phaseStr + ")"); - - long startTime = System.currentTimeMillis(); - List candidateSAs = new ArrayList<>(); - for (final SpellAbility sa : ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player)) { + } + + private List getCandidateSpellsAndAbilities(List all) { + List candidateSAs = ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player); + int writeIndex = 0; + for (int i = 0; i < candidateSAs.size(); i++) { + SpellAbility sa = candidateSAs.get(i); if (sa.isManaAbility()) { continue; } @@ -68,20 +67,59 @@ public class SpellAbilityPicker { if (opinion != AiPlayDecision.WillPlay) continue; - candidateSAs.add(sa); + candidateSAs.set(writeIndex, sa); + writeIndex++; + } + candidateSAs.subList(writeIndex, candidateSAs.size()).clear(); + return candidateSAs; + } + + public SpellAbility chooseSpellAbilityToPlay(SimulationController controller, List all, boolean skipCounter) { + printOutput = (controller == null); + + // FIXME: This is wasteful, we should re-use the same simulator... + GameSimulator simulator = new GameSimulator(controller, game, player); + Score origGameScore = simulator.getScoreForOrigGame(); + List candidateSAs = getCandidateSpellsAndAbilities(all); + if (controller != null) { + // This is a recursion during a higher-level simulation. Just return the head of the best + // sequence directly, no need to create a Plan object. + return chooseSpellAbilityToPlayImpl(controller, candidateSAs, origGameScore); } - if (candidateSAs.isEmpty()) { - return null; + printPhaseInfo(); + SpellAbility sa = getPlannedSpellAbility(origGameScore, candidateSAs); + if (sa != null) { + return sa; } + createNewPlan(origGameScore, candidateSAs); + return getPlannedSpellAbility(origGameScore, candidateSAs); + } + + private void createNewPlan(Score origGameScore, List candidateSAs) { + plan = null; + SimulationController controller = new SimulationController(origGameScore); + SpellAbility sa = chooseSpellAbilityToPlayImpl(controller, candidateSAs, origGameScore); + if (sa == null) { + print("No good plan at this time"); + return; + } + + plan = controller.getBestPlan(); + print("New plan with score " + controller.getBestScore() + ":"); + int i = 0; + for (Plan.Decision d : plan.getDecisions()) { + print(++i + ". " + d); + } + } + + private SpellAbility chooseSpellAbilityToPlayImpl(SimulationController controller, List candidateSAs, Score origGameScore) { + long startTime = System.currentTimeMillis(); + SpellAbility bestSa = null; - GameSimulator simulator = new GameSimulator(controller, game, player); - // FIXME: This is wasteful, we should re-use the same simulator... - Score origGameScore = simulator.getScoreForOrigGame(); Score bestSaValue = origGameScore; print("Evaluating... (orig score = " + origGameScore + ")"); for (final SpellAbility sa : candidateSAs) { - print(abilityToString(sa));; Score value = evaluateSa(controller, sa); if (value.value > bestSaValue.value) { bestSaValue = value; @@ -103,6 +141,41 @@ public class SpellAbilityPicker { this.bestScore = bestSaValue; return bestSa; } + + private SpellAbility getPlannedSpellAbility(Score origGameScore, List availableSAs) { + if (plan != null && plan.hasNextDecision()) { + boolean badTargets = false; + boolean saNotFound = false; + Plan.Decision decision = plan.selectNextDecision(); + if (decision.initialScore.equals(origGameScore)) { + // TODO: Other safeguards like list of SAs and maybe the index and such? + for (final SpellAbility sa : availableSAs) { + if (sa.toString().equals(decision.sa)) { + if (decision.targets != null) { + PossibleTargetSelector selector = new PossibleTargetSelector(game, player, sa); + if (!selector.selectTargets(decision.targets)) { + badTargets = true; + break; + } + } + print("Planned decision " + plan.getNextDecisionIndex() + ": " + abilityToString(sa) + " " + decision.choice); + return sa; + } + } + saNotFound = true; + } + print("Failed to continue planned action (" + decision.sa + "). Cause:"); + if (badTargets) { + print(" Bad targets!"); + } else if (saNotFound) { + print(" Couldn't find spell/ability!"); + } else { + print(" Unexpected game score (" + decision.initialScore + " vs. expected " + origGameScore + ")!"); + } + plan = null; + } + return null; + } public Score getScoreForChosenAbility() { return bestScore; @@ -161,12 +234,11 @@ public class SpellAbilityPicker { return AiPlayDecision.WillPlay; } - private Score evaluateSa(SimulationController controller, SpellAbility sa) { - GameSimulator.debugPrint("Evaluate SA: " + sa); + private Score evaluateSa(final SimulationController controller, SpellAbility sa) { + controller.evaluateSpellAbility(sa); Score bestScore = new Score(Integer.MIN_VALUE); if (!sa.usesTargeting()) { - // TODO: Refactor this into a general decision tree. Interceptor interceptor = new Interceptor() { private int numChoices = -1; private int nextChoice = 0; @@ -185,7 +257,9 @@ public class SpellAbilityPicker { } numChoices = uniqueCards.size(); nextChoice++; - GameSimulator.debugPrint("Trying out choice " + choice); + if (choice != null) { + controller.evaluateCardChoice(choice); + } return choice; } @@ -204,30 +278,33 @@ public class SpellAbilityPicker { GameSimulator simulator = new GameSimulator(controller, game, player); simulator.setInterceptor(interceptor); Score score = simulator.simulateSpellAbility(sa); + if (interceptor.getLastChoice() != null) { + controller.doneEvaluating(score); + } if (score.value > bestScore.value) { bestScore = score; - Card choice = interceptor.getLastChoice(); - if (choice != null) { - bestScore.choice = choice.getName(); - } } } while (interceptor.hasMoreChoices()); + controller.doneEvaluating(bestScore); return bestScore; } - GameSimulator.debugPrint("Checking out targets"); PossibleTargetSelector selector = new PossibleTargetSelector(game, player, sa); TargetChoices tgt = null; while (selector.selectNextTargets()) { - GameSimulator.debugPrint("Trying targets: " + sa.getTargets().getTargetedString()); + controller.evaluateTargetChoices(selector.getLastSelectedTargets()); GameSimulator simulator = new GameSimulator(controller, game, player); Score score = simulator.simulateSpellAbility(sa); + controller.doneEvaluating(score); + // TODO: Get rid of the below when no longer needed. if (score.value > bestScore.value) { bestScore = score; tgt = sa.getTargets(); sa.resetTargets(); } } + controller.doneEvaluating(bestScore); + if (tgt != null) { sa.setTargets(tgt); } @@ -240,12 +317,15 @@ public class SpellAbilityPicker { return interceptor.chooseCard(fetchList); } // TODO: Make the below more robust? - if (bestScore != null && bestScore.choice != null) { + if (plan != null && plan.getSelectedDecision() != null) { + String choice = plan.getSelectedDecision().choice; for (Card c : fetchList) { - if (c.getName().equals(bestScore.choice)) { + if (c.getName().equals(choice)) { + print(" Planned choice: " + c); return c; } } + print("Failed to use planned choice (" + choice + "). Not found!"); } return ChangeZoneAi.chooseCardToHiddenOriginChangeZone(destination, origin, sa, fetchList, player2, decider); } diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulatorTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulatorTest.java index fc6445c26c5..66e494ec94d 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulatorTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/GameSimulatorTest.java @@ -10,6 +10,7 @@ import forge.GuiBase; import forge.GuiDesktop; import forge.ai.ComputerUtilAbility; import forge.ai.LobbyPlayerAi; +import forge.ai.simulation.GameStateEvaluator.Score; import forge.card.CardStateName; import forge.deck.Deck; import forge.game.Game; @@ -49,11 +50,11 @@ public class GameSimulatorTest extends TestCase { } return game; } - + private GameSimulator createSimulator(Game game, Player p) { - return new GameSimulator(new SimulationController(), game, p); + return new GameSimulator(new SimulationController(new Score(0)), game, p); } - + private Card findCardWithName(Game game, String name) { for (Card c : game.getCardsIn(ZoneType.Battlefield)) { if (c.getName().equals(name)) {