From c508f07910da18f836e0b15ad65a452ce2bb1be8 Mon Sep 17 00:00:00 2001 From: BetaSteward Date: Fri, 4 Nov 2011 23:02:32 -0400 Subject: [PATCH] added monte carlo AI - still needs some work --- .../Mage.Player.AIMCTS/pom.xml | 62 +++ .../mage/player/ai/ComputerPlayerMCTS.java | 302 +++++++++++++ .../src/mage/player/ai/MCTSNode.java | 284 ++++++++++++ .../src/mage/player/ai/MCTSPlayer.java | 304 +++++++++++++ .../mage/player/ai/SimulatedPlayerMCTS.java | 418 ++++++++++++++++++ Mage.Server.Plugins/pom.xml | 1 + Mage.Server/config/config.xml | 1 + Mage.Server/plugins/mage-player-aimcts.jar | Bin 0 -> 22793 bytes 8 files changed, 1372 insertions(+) create mode 100644 Mage.Server.Plugins/Mage.Player.AIMCTS/pom.xml create mode 100644 Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java create mode 100644 Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java create mode 100644 Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSPlayer.java create mode 100644 Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SimulatedPlayerMCTS.java create mode 100644 Mage.Server/plugins/mage-player-aimcts.jar diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/pom.xml b/Mage.Server.Plugins/Mage.Player.AIMCTS/pom.xml new file mode 100644 index 00000000000..da320069205 --- /dev/null +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/pom.xml @@ -0,0 +1,62 @@ + + + + 4.0.0 + + + org.mage + Mage-Server-Plugins + 0.8.1 + + + Mage-Player-AI-MCTS + jar + Mage Player AI MCTS + + + + log4j + log4j + 1.2.14 + jar + + + ${project.groupId} + Mage + ${project.version} + + + ${project.groupId} + Mage-Player-AI + ${project.version} + + + + + src + + + org.apache.maven.plugins + maven-compiler-plugin + 2.0.2 + + 1.6 + 1.6 + + + + maven-resources-plugin + + UTF-8 + + + + + + mage-player-aimcts + + + + + diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java new file mode 100644 index 00000000000..8700aa8d310 --- /dev/null +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java @@ -0,0 +1,302 @@ +/* + * Copyright 2011 BetaSteward_at_googlemail.com. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY BetaSteward_at_googlemail.com ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BetaSteward_at_googlemail.com OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of BetaSteward_at_googlemail.com. + */ +package mage.player.ai; + +import java.util.List; +import java.util.UUID; +import mage.Constants.RangeOfInfluence; +import mage.abilities.Ability; +import mage.abilities.ActivatedAbility; +import mage.game.Game; +import mage.game.combat.Combat; +import mage.game.combat.CombatGroup; +import mage.game.permanent.Permanent; +import mage.players.Player; +import org.apache.log4j.Logger; + +/** + * + * @author BetaSteward_at_googlemail.com + */ +public class ComputerPlayerMCTS extends ComputerPlayer implements Player { + + protected transient MCTSNode root; + protected int thinkTime; + private final static transient Logger logger = Logger.getLogger(ComputerPlayerMCTS.class); + + public ComputerPlayerMCTS(String name, RangeOfInfluence range, int skill) { + super(name, range); + human = false; + thinkTime = skill; + } + + protected ComputerPlayerMCTS(UUID id) { + super(id); + } + + public ComputerPlayerMCTS(final ComputerPlayerMCTS player) { + super(player); + } + + @Override + public ComputerPlayerMCTS copy() { + return new ComputerPlayerMCTS(this); + } + + @Override + public void priority(Game game) { + getNextAction(game); + Ability ability = root.getAction(); + activateAbility((ActivatedAbility)ability, game); + } + + protected void calculateActions(Game game) { + if (root == null) { + Game sim = createMCTSGame(game); + MCTSPlayer player = (MCTSPlayer) sim.getPlayer(playerId); + player.setNextAction(MCTSPlayer.NextAction.PRIORITY); + root = new MCTSNode(sim); + } + applyMCTS(); + root = root.bestChild(); + root.emancipate(); + } + + protected void getNextAction(Game game) { + if (root != null) { + root = root.getMatchingState(game.getState().getValue().hashCode()); + if (root != null) + root.emancipate(); + } + calculateActions(game); + } + +// @Override +// public boolean choose(Outcome outcome, Target target, UUID sourceId, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean choose(Outcome outcome, Target target, UUID sourceId, Game game, Map options) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean choose(Outcome outcome, Cards cards, TargetCard target, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean chooseMulligan(Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean chooseUse(Outcome outcome, String message, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public boolean choose(Outcome outcome, Choice choice, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } + +// @Override +// public boolean playMana(ManaCost unpaid, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } + +// @Override +// public boolean playXMana(VariableManaCost cost, ManaCosts costs, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public int chooseEffect(List rEffects, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public TriggeredAbility chooseTriggeredAbility(TriggeredAbilities abilities, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public Mode chooseMode(Modes modes, Ability source, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } + + @Override + public void selectAttackers(Game game) { + Game sim = createMCTSGame(game); + MCTSPlayer player = (MCTSPlayer) sim.getPlayer(playerId); + player.setNextAction(MCTSPlayer.NextAction.SELECT_ATTACKERS); + root = new MCTSNode(sim); + applyMCTS(); + Combat combat = root.bestChild().getCombat(); + UUID opponentId = game.getCombat().getDefenders().iterator().next(); + for (UUID attackerId: combat.getAttackers()) { + this.declareAttacker(attackerId, opponentId, game); + } + } + + @Override + public void selectBlockers(Game game) { + Game sim = createMCTSGame(game); + MCTSPlayer player = (MCTSPlayer) sim.getPlayer(playerId); + player.setNextAction(MCTSPlayer.NextAction.SELECT_BLOCKERS); + root = new MCTSNode(sim); + applyMCTS(); + Combat combat = root.bestChild().getCombat(); + List groups = game.getCombat().getGroups(); + for (int i = 0; i < groups.size(); i++) { + if (i < combat.getGroups().size()) { + for (UUID blockerId: combat.getGroups().get(i).getBlockers()) { + this.declareBlocker(blockerId, groups.get(i).getAttackers().get(0), game); + } + } + } + } + +// @Override +// public UUID chooseAttackerOrder(List attacker, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public UUID chooseBlockerOrder(List blockers, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public void assignDamage(int damage, List targets, String singleTargetName, UUID sourceId, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public int getAmount(int min, int max, String message, Game game) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public void sideboard(Match match, Deck deck) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public void construct(Tournament tournament, Deck deck) { +// throw new UnsupportedOperationException("Not supported yet."); +// } +// +// @Override +// public void pickCard(List cards, Deck deck, Draft draft) { +// throw new UnsupportedOperationException("Not supported yet."); +// } + + protected void applyMCTS() { + long startTime = System.nanoTime(); + long endTime = startTime + (thinkTime * 1000000000l); + MCTSNode current; + + if (root.getNumChildren() == 1) + //there is only one possible action + return; + + logger.info("applyMCTS - Thinking for " + (endTime - startTime)/1000000000.0 + "s"); + while (true) { + long currentTime = System.nanoTime(); + logger.info("Remaining time: " + (endTime - currentTime)/1000000000.0 + "s"); + if (currentTime > endTime) + break; + current = root; + + // Selection + while (!current.isLeaf()) { + current = current.select(); + } + + int result; + if (!current.isTerminal()) { + // Expansion + current.expand(); + + if (current == root && current.getNumChildren() == 1) + //there is only one possible action + return; + + // Simulation + current = current.select(); + result = current.simulate(this.playerId); + } + else { + result = current.isWinner(this.playerId)?1:0; + } + // Backpropagation + current.backpropagate(result); + } + logger.info("Created " + root.getNodeCount() + " nodes"); + return; + } + + /** + * Copies game and replaces all players in copy with mcts players + * Shuffles each players library so that there is no knowledge of its order + * + * @param game + * @return a new game object with simulated players + */ + protected Game createMCTSGame(Game game) { + Game mcts = game.copy(); + + for (Player copyPlayer: mcts.getState().getPlayers().values()) { + Player origPlayer = game.getState().getPlayers().get(copyPlayer.getId()); + MCTSPlayer newPlayer = new MCTSPlayer(copyPlayer.getId()); + newPlayer.restore(origPlayer); + newPlayer.shuffleLibrary(mcts); + mcts.getState().getPlayers().put(copyPlayer.getId(), newPlayer); + } + mcts.setSimulation(true); + return mcts; + } + +} diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java new file mode 100644 index 00000000000..82dc47d2dc4 --- /dev/null +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java @@ -0,0 +1,284 @@ +/* + * Copyright 2011 BetaSteward_at_googlemail.com. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY BetaSteward_at_googlemail.com ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BetaSteward_at_googlemail.com OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of BetaSteward_at_googlemail.com. + */ +package mage.player.ai; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import mage.Constants.PhaseStep; +import mage.abilities.Ability; +import mage.abilities.ActivatedAbility; +import mage.game.Game; +import mage.game.GameState; +import mage.game.combat.Combat; +import mage.game.combat.CombatGroup; +import mage.game.turn.Step.StepPart; +import mage.players.Player; +import org.apache.log4j.Logger; + +/** + * + * @author BetaSteward_at_googlemail.com + */ +public class MCTSNode { + + private static final double selectionCoefficient = 1; + private final static transient Logger logger = Logger.getLogger(MCTSNode.class); + + private int visits = 0; + private int wins = 0; + private MCTSNode parent; + private List children = new ArrayList(); + private Ability action; + private Combat combat; + private Game game; + private int stateValue; + + private static int nodeCount; + + public MCTSNode(Game game) { + this.game = game; + this.stateValue = game.getState().getValue().hashCode(); + nodeCount = 1; + } + + protected MCTSNode(MCTSNode parent, Game game, int state, Ability action) { + this.game = game; + this.stateValue = state; + this.parent = parent; + this.action = action; + nodeCount++; + } + + protected MCTSNode(MCTSNode parent, Game game, int state, Combat combat) { + this.game = game; + this.stateValue = state; + this.parent = parent; + this.combat = combat; + nodeCount++; + } + + public MCTSNode select() { + double bestValue = Double.NEGATIVE_INFINITY; + MCTSNode bestChild = null; +// logger.info("start select"); + if (children.size() == 1) { + return children.get(0); + } + for (MCTSNode node: children) { + double uct; + if (node.visits > 0) + uct = (node.wins / (node.visits + 1.0)) + (selectionCoefficient * Math.sqrt(Math.log(visits + 1.0) / (node.visits + 1.0))); + else + // ensure that a random unvisited node is played first + uct = 10000 + 1000 * Math.random(); +// logger.info("uct: " + uct); + if (uct > bestValue) { + bestChild = node; + bestValue = uct; + } + } +// logger.info("stop select"); + return bestChild; + } + + public void expand() { + MCTSPlayer player; + if (game.getStep().getStepPart() == StepPart.PRIORITY) + player = (MCTSPlayer) game.getPlayer(game.getPriorityPlayerId()); + else { + if (game.getStep().getType() == PhaseStep.DECLARE_BLOCKERS) + player = (MCTSPlayer) game.getPlayer(game.getCombat().getDefenders().iterator().next()); + else + player = (MCTSPlayer) game.getPlayer(game.getActivePlayerId()); + } + if (player.getNextAction() == null) { + logger.fatal("next action is null"); + } + switch (player.getNextAction()) { + case PRIORITY: + logger.info("Priority for player:" + player.getName() + " turn: " + game.getTurnNum() + " phase: " + game.getPhase().getType() + " step: " + game.getStep().getType()); + List abilities = player.getPlayableOptions(game); + for (Ability ability: abilities) { + Game sim = game.copy(); + int simState = sim.getState().getValue().hashCode(); + logger.info("expand " + ability.toString()); + MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); + simPlayer.activateAbility((ActivatedAbility)ability, sim); + sim.resume(); + children.add(new MCTSNode(this, sim, simState, ability)); + } + break; + case SELECT_ATTACKERS: + logger.info("Select attackers:" + player.getName()); + List> attacks = player.getAttacks(game); + UUID defenderId = game.getOpponents(player.getId()).iterator().next(); + for (List attack: attacks) { + Game sim = game.copy(); + int simState = sim.getState().getValue().hashCode(); + MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); + for (UUID attackerId: attack) { + simPlayer.declareAttacker(attackerId, defenderId, sim); + } + sim.resume(); + children.add(new MCTSNode(this, sim, simState, sim.getCombat())); + } + break; + case SELECT_BLOCKERS: + logger.info("Select blockers:" + player.getName()); + List>> blocks = player.getBlocks(game); + for (List> block: blocks) { + Game sim = game.copy(); + int simState = sim.getState().getValue().hashCode(); + MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); + List groups = sim.getCombat().getGroups(); + for (int i = 0; i < groups.size(); i++) { + if (i < block.size()) { + for (UUID blockerId: block.get(i)) { + simPlayer.declareBlocker(blockerId, groups.get(i).getAttackers().get(0), sim); + } + } + } + sim.resume(); + children.add(new MCTSNode(this, sim, simState, sim.getCombat())); + } + break; + } + } + + public int simulate(UUID playerId) { + long startTime = System.nanoTime(); + Game sim = createSimulation(game); + sim.resume(); + long duration = System.nanoTime() - startTime; + int retVal = 0; + for (Player simPlayer: sim.getPlayers().values()) { + logger.info(simPlayer.getName() + " calculated " + ((SimulatedPlayerMCTS)simPlayer).getActionCount() + " actions in " + duration/1000000000.0 + "s"); + if (simPlayer.getId().equals(playerId) && simPlayer.hasWon()) { + logger.info("AI won the simulation"); + retVal = 1; + } + } + return retVal; + } + + public void backpropagate(int result) { + if (result == 1) + wins++; + visits++; + if (parent != null) + parent.backpropagate(result); + } + + public boolean isLeaf() { + return children.isEmpty(); + } + + public MCTSNode bestChild() { + double bestCount = -1; + MCTSNode bestChild = null; + for (MCTSNode node: children) { + if (node.visits > bestCount) { + bestChild = node; + bestCount = node.visits; + } + } + return bestChild; + } + + public void emancipate() { + this.parent = null; + } + + public Ability getAction() { + return action; + } + + public int getNumChildren() { + return children.size(); + } + + public MCTSNode getParent() { + return parent; + } + + public Combat getCombat() { + return combat; + } + + public int getNodeCount() { + return nodeCount; + } + + /** + * Copies game and replaces all players in copy with simulated players + * Shuffles each players library so that there is no knowledge of its order + * + * @param game + * @return a new game object with simulated players + */ + protected Game createSimulation(Game game) { + Game sim = game.copy(); + + for (Player copyPlayer: sim.getState().getPlayers().values()) { + Player origPlayer = game.getState().getPlayers().get(copyPlayer.getId()).copy(); + SimulatedPlayerMCTS newPlayer = new SimulatedPlayerMCTS(copyPlayer.getId(), true); + newPlayer.restore(origPlayer); + newPlayer.shuffleLibrary(sim); + sim.getState().getPlayers().put(copyPlayer.getId(), newPlayer); + } + sim.setSimulation(true); + return sim; + } + + public boolean isTerminal() { + return game.isGameOver(); + } + + public boolean isWinner(UUID playerId) { + Player player = game.getPlayer(playerId); + if (player != null && player.hasWon()) + return true; + return false; + } + + public MCTSNode getMatchingState(int state) { + for (MCTSNode node: children) { +// logger.info(state); +// logger.info(node.stateValue); + if (node.stateValue == state) { + return node; + } + MCTSNode match = node.getMatchingState(state); + if (match != null) + return node; + } + return null; + } + +} diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSPlayer.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSPlayer.java new file mode 100644 index 00000000000..ef9f94e245a --- /dev/null +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSPlayer.java @@ -0,0 +1,304 @@ +/* + * Copyright 2011 BetaSteward_at_googlemail.com. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY BetaSteward_at_googlemail.com ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BetaSteward_at_googlemail.com OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of BetaSteward_at_googlemail.com. + */ +package mage.player.ai; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import mage.abilities.Ability; +import mage.abilities.SpellAbility; +import mage.abilities.common.PassAbility; +import mage.abilities.costs.mana.GenericManaCost; +import mage.game.Game; +import mage.game.combat.Combat; +import mage.game.permanent.Permanent; +import org.apache.log4j.Logger; + +/** + * + * @author BetaSteward_at_googlemail.com + */ +public class MCTSPlayer extends ComputerPlayer { + + private final static transient Logger logger = Logger.getLogger(MCTSPlayer.class); + + protected PassAbility pass = new PassAbility(); + + private NextAction nextAction; + + public enum NextAction { + PRIORITY, SELECT_ATTACKERS, SELECT_BLOCKERS; + } + + public MCTSPlayer(UUID id) { + super(id); + this.pass.setControllerId(id); + } + + public MCTSPlayer(final MCTSPlayer player) { + super(player); + this.pass = player.pass.copy(); + this.nextAction = player.nextAction; + } + + @Override + public MCTSPlayer copy() { + return new MCTSPlayer(this); + } + + protected List getPlayableAbilities(Game game) { + List playables = getPlayable(game, true); + playables.add(pass); + return playables; + } + + public List getPlayableOptions(Game game) { + List all = new ArrayList(); + List playables = getPlayableAbilities(game); + for (Ability ability: playables) { + List options = game.getPlayer(playerId).getPlayableOptions(ability, game); + if (options.isEmpty()) { + if (ability.getManaCosts().getVariableCosts().size() > 0) { + simulateVariableCosts(ability, all, game); + } + else { + all.add(ability); + } + } + else { + for (Ability option: options) { + if (ability.getManaCosts().getVariableCosts().size() > 0) { + simulateVariableCosts(option, all, game); + } + else { + all.add(option); + } + } + } + } + return all; + } + + protected void simulateVariableCosts(Ability ability, List options, Game game) { + int numAvailable = getAvailableManaProducers(game).size() - ability.getManaCosts().convertedManaCost(); + int start = 0; + if (!(ability instanceof SpellAbility)) { + //only use x=0 on spell abilities + if (numAvailable == 0) + return; + else + start = 1; + } + for (int i = start; i < numAvailable; i++) { + Ability newAbility = ability.copy(); + newAbility.addManaCost(new GenericManaCost(i)); + options.add(newAbility); + } + } + + public List> getAttacks(Game game) { + List> engagements = new ArrayList>(); + List attackersList = super.getAvailableAttackers(game); + //use binary digits to calculate powerset of attackers + int powerElements = (int) Math.pow(2, attackersList.size()); + StringBuilder binary = new StringBuilder(); + for (int i = powerElements - 1; i >= 0; i--) { + binary.setLength(0); + binary.append(Integer.toBinaryString(i)); + while (binary.length() < attackersList.size()) { + binary.insert(0, "0"); + } + List engagement = new ArrayList(); + for (int j = 0; j < attackersList.size(); j++) { + if (binary.charAt(j) == '1') + engagement.add(attackersList.get(j).getId()); + } + engagements.add(engagement); + } + return engagements; + } + + public List>> getBlocks(Game game) { + List>> engagements = new ArrayList>>(); + int numGroups = game.getCombat().getGroups().size(); + if (numGroups == 0) return engagements; + + //add a node with no blockers + List> engagement = new ArrayList>(); + for (int i = 0; i < numGroups; i++) { + engagement.add(new ArrayList()); + } + engagements.add(engagement); + + List blockers = getAvailableBlockers(game); + addBlocker(game, engagement, blockers, engagements); + + return engagements; + } + + private List> copyEngagement(List> engagement) { + List> newEngagement = new ArrayList>(); + for (List group: engagement) { + newEngagement.add(new ArrayList(group)); + } + return newEngagement; + } + + protected void addBlocker(Game game, List> engagement, List blockers, List>> engagements) { + if (blockers.isEmpty()) + return; + int numGroups = game.getCombat().getGroups().size(); + //try to block each attacker with each potential blocker + Permanent blocker = blockers.get(0); +// if (logger.isDebugEnabled()) +// logger.debug("simulating -- block:" + blocker); + List remaining = remove(blockers, blocker); + for (int i = 0; i < numGroups; i++) { + if (game.getCombat().getGroups().get(i).canBlock(blocker, game)) { + List>newEngagement = copyEngagement(engagement); + newEngagement.get(i).add(blocker.getId()); + engagements.add(newEngagement); +// logger.debug("simulating -- found redundant block combination"); + addBlocker(game, newEngagement, remaining, engagements); // and recurse minus the used blocker + } + } + addBlocker(game, engagement, remaining, engagements); + } + + public NextAction getNextAction() { + return nextAction; + } + + public void setNextAction(NextAction action) { + this.nextAction = action; + } + + @Override + public void priority(Game game) { +// logger.info("Paused for Priority for player:" + getName()); + game.pause(); + nextAction = NextAction.PRIORITY; + } + +// @Override +// public boolean choose(Outcome outcome, Target target, UUID sourceId, Game game) { +// game.end(); +// } +// +// @Override +// public boolean choose(Outcome outcome, Target target, UUID sourceId, Game game, Map options) { +// game.end(); +// } +// +// @Override +// public boolean choose(Outcome outcome, Cards cards, TargetCard target, Game game) { +// game.end(); +// } +// +// @Override +// public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) { +// game.end(); +// } +// +// @Override +// public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { +// game.end(); +// } +// +// @Override +// public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) { +// game.end(); +// } +// +// @Override +// public boolean chooseMulligan(Game game) { +// game.end(); +// } +// +// @Override +// public boolean chooseUse(Outcome outcome, String message, Game game) { +// game.end(); +// } +// +// @Override +// public boolean choose(Outcome outcome, Choice choice, Game game) { +// game.end(); +// } +// +// @Override +// public int chooseEffect(List rEffects, Game game) { +// game.end(); +// } +// +// @Override +// public TriggeredAbility chooseTriggeredAbility(TriggeredAbilities abilities, Game game) { +// game.end(); +// } +// +// @Override +// public Mode chooseMode(Modes modes, Ability source, Game game) { +// game.end(); +// } + + @Override + public void selectAttackers(Game game) { +// logger.info("Paused for select attackers for player:" + getName()); + game.pause(); + nextAction = NextAction.SELECT_ATTACKERS; + } + + @Override + public void selectBlockers(Game game) { +// logger.info("Paused for select blockers for player:" + getName()); + game.pause(); + nextAction = NextAction.SELECT_BLOCKERS; + } + +// @Override +// public UUID chooseAttackerOrder(List attacker, Game game) { +// game.end(); +// } +// +// @Override +// public UUID chooseBlockerOrder(List blockers, Game game) { +// game.end(); +// } +// +// @Override +// public void assignDamage(int damage, List targets, String singleTargetName, UUID sourceId, Game game) { +// game.end(); +// } +// +// @Override +// public int getAmount(int min, int max, String message, Game game) { +// game.end(); +// } + +} diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SimulatedPlayerMCTS.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SimulatedPlayerMCTS.java new file mode 100644 index 00000000000..77ba63399b5 --- /dev/null +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SimulatedPlayerMCTS.java @@ -0,0 +1,418 @@ +/* + * Copyright 2011 BetaSteward_at_googlemail.com. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY BetaSteward_at_googlemail.com ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BetaSteward_at_googlemail.com OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of BetaSteward_at_googlemail.com. + */ +package mage.player.ai; + +import java.io.Serializable; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import mage.Constants.Outcome; +import mage.Constants.Zone; +import mage.abilities.Ability; +import mage.abilities.ActivatedAbility; +import mage.abilities.Mode; +import mage.abilities.Modes; +import mage.abilities.TriggeredAbilities; +import mage.abilities.TriggeredAbility; +import mage.abilities.costs.mana.GenericManaCost; +import mage.abilities.costs.mana.ManaCost; +import mage.abilities.costs.mana.ManaCosts; +import mage.abilities.costs.mana.VariableManaCost; +import mage.abilities.effects.ReplacementEffect; +import mage.abilities.mana.ManaAbility; +import mage.cards.Card; +import mage.cards.Cards; +import mage.choices.Choice; +import mage.game.Game; +import mage.game.combat.CombatGroup; +import mage.game.permanent.Permanent; +import mage.game.stack.StackAbility; +import mage.players.Player; +import mage.target.Target; +import mage.target.TargetAmount; +import mage.target.TargetCard; +import org.apache.log4j.Logger; + +/** + * + * plays randomly + * + * @author BetaSteward_at_googlemail.com + */ +public class SimulatedPlayerMCTS extends MCTSPlayer { + + private boolean isSimulatedPlayer; + private static Random rnd = new Random(); + private int actionCount = 0; + private final static transient Logger logger = Logger.getLogger(SimulatedPlayerMCTS.class); + + public SimulatedPlayerMCTS(UUID id, boolean isSimulatedPlayer) { + super(id); + this.isSimulatedPlayer = isSimulatedPlayer; + } + + public SimulatedPlayerMCTS(final SimulatedPlayerMCTS player) { + super(player); + this.isSimulatedPlayer = player.isSimulatedPlayer; + } + + @Override + public SimulatedPlayerMCTS copy() { + return new SimulatedPlayerMCTS(this); + } + + public boolean isSimulatedPlayer() { + return this.isSimulatedPlayer; + } + + public int getActionCount() { + return actionCount; + } + + @Override + public void priority(Game game) { +// logger.info("priority"); + while (true) { + List playables = getPlayableAbilities(game); + Ability ability; + if (playables.size() == 1) + ability = playables.get(0); + else + ability = playables.get(rnd.nextInt(playables.size())); + List options = getPlayableOptions(ability, game); + if (!options.isEmpty()) { + if (options.size() == 1) + ability = options.get(0); + else + ability = options.get(rnd.nextInt(options.size())); + } + if (ability.getManaCosts().getVariableCosts().size() > 0) { + int amount = getAvailableManaProducers(game).size() - ability.getManaCosts().convertedManaCost(); + if (amount > 0) + ability.addManaCost(new GenericManaCost(rnd.nextInt(amount))); + } +// logger.info("simulate " + ability.toString()); + activateAbility((ActivatedAbility) ability, game); + + actionCount++; + if (ability.isUsesStack()) + break; + } + } + + @Override + public boolean triggerAbility(TriggeredAbility source, Game game) { +// logger.info("trigger"); + if (source != null && source.canChooseTarget(game)) { + Ability ability; + List options = getPlayableOptions(source, game); + if (options.isEmpty()) { + ability = source; + } + else { + if (options.size() == 1) + ability = options.get(0); + else + ability = options.get(rnd.nextInt(options.size())); + } + if (ability.isUsesStack()) { + game.getStack().push(new StackAbility(ability, playerId)); + if (ability.activate(game, false)) { + actionCount++; + return true; + } + } else { + if (ability.activate(game, false)) { + ability.resolve(game); + actionCount++; + return true; + } + } + } + return false; + } + + @Override + public void selectAttackers(Game game) { + //useful only for two player games - will only attack first opponent +// logger.info("select attackers"); + UUID defenderId = game.getOpponents(playerId).iterator().next(); + List attackersList = super.getAvailableAttackers(game); + //use binary digits to calculate powerset of attackers + int powerElements = (int) Math.pow(2, attackersList.size()); + int value = rnd.nextInt(powerElements); + StringBuilder binary = new StringBuilder(); + binary.append(Integer.toBinaryString(value)); + while (binary.length() < attackersList.size()) { + binary.insert(0, "0"); //pad with zeros + } + for (int i = 0; i < attackersList.size(); i++) { + if (binary.charAt(i) == '1') + game.getCombat().declareAttacker(attackersList.get(i).getId(), defenderId, game); + } + actionCount++; + } + + @Override + public void selectBlockers(Game game) { +// logger.info("select blockers"); + int numGroups = game.getCombat().getGroups().size(); + if (numGroups == 0) return; + + List blockers = getAvailableBlockers(game); + for (Permanent blocker: blockers) { + int check = rnd.nextInt(numGroups + 1); + if (check < numGroups) { + CombatGroup group = game.getCombat().getGroups().get(check); + if (group.getAttackers().size() > 0) + this.declareBlocker(blocker.getId(), group.getAttackers().get(0), game); + } + } + actionCount++; + } + + @Override + public void abort() { + abort = true; + } + + protected boolean chooseRandom(Target target, Game game) { + Set possibleTargets = target.possibleTargets(playerId, game); + if (possibleTargets.isEmpty()) + return !target.isRequired(); + if (!target.isRequired()) { + if (rnd.nextInt(possibleTargets.size() + 1) == 0) { + return false; + } + } + if (possibleTargets.size() == 1) { + target.add(possibleTargets.iterator().next(), game); + return true; + } + Iterator it = possibleTargets.iterator(); + int targetNum = rnd.nextInt(possibleTargets.size()); + UUID targetId = it.next(); + for (int i = 0; i < targetNum; i++) { + targetId = it.next(); + } + target.add(targetId, game); + return true; + } + + protected boolean chooseRandomTarget(Target target, Ability source, Game game) { + Set possibleTargets = target.possibleTargets(source==null?null:source.getSourceId(), playerId, game); + if (possibleTargets.isEmpty()) + return false; + if (!target.isRequired()) { + if (rnd.nextInt(possibleTargets.size() + 1) == 0) { + return false; + } + } + if (possibleTargets.size() == 1) { + target.addTarget(possibleTargets.iterator().next(), source, game); + return true; + } + Iterator it = possibleTargets.iterator(); + int targetNum = rnd.nextInt(possibleTargets.size()); + UUID targetId = it.next(); + for (int i = 0; i < targetNum; i++) { + targetId = it.next(); + } + target.addTarget(targetId, source, game); + return true; + } + + @Override + public boolean choose(Outcome outcome, Target target, UUID sourceId, Game game) { + return chooseRandom(target, game); + } + + @Override + public boolean choose(Outcome outcome, Target target, UUID sourceId, Game game, Map options) { + return chooseRandom(target, game); + } + + @Override + public boolean choose(Outcome outcome, Cards cards, TargetCard target, Game game) { + if (cards.isEmpty()) + return !target.isRequired(); + Card card = cards.getRandom(game); + target.add(card.getId(), game); + return true; + } + + @Override + public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) { + return chooseRandomTarget(target, source, game); + } + + @Override + public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { + if (cards.isEmpty()) + return !target.isRequired(); + Card card = cards.getRandom(game); + target.addTarget(card.getId(), source, game); + return true; + } + + @Override + public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) { + Set possibleTargets = target.possibleTargets(source==null?null:source.getSourceId(), playerId, game); + if (possibleTargets.isEmpty()) + return !target.isRequired(); + if (!target.isRequired()) { + if (rnd.nextInt(possibleTargets.size() + 1) == 0) { + return false; + } + } + if (possibleTargets.size() == 1) { + target.addTarget(possibleTargets.iterator().next(), target.getAmountRemaining(), source, game); + return true; + } + Iterator it = possibleTargets.iterator(); + int targetNum = rnd.nextInt(possibleTargets.size()); + UUID targetId = it.next(); + for (int i = 0; i < targetNum; i++) { + targetId = it.next(); + } + target.addTarget(targetId, rnd.nextInt(target.getAmountRemaining()) + 1, source, game); + return true; + } + + @Override + public boolean chooseMulligan(Game game) { + return rnd.nextBoolean(); + } + + @Override + public boolean chooseUse(Outcome outcome, String message, Game game) { + return rnd.nextBoolean(); + } + + @Override + public boolean choose(Outcome outcome, Choice choice, Game game) { + Iterator it = choice.getChoices().iterator(); + String sChoice = it.next(); + int choiceNum = rnd.nextInt(choice.getChoices().size()); + for (int i = 0; i < choiceNum; i++) { + sChoice = it.next(); + } + choice.setChoice(sChoice); + return true; + } + + @Override + public boolean playXMana(VariableManaCost cost, ManaCosts costs, Game game) { + for (Permanent perm: this.getAvailableManaProducers(game)) { + for (ManaAbility ability: perm.getAbilities().getAvailableManaAbilities(Zone.BATTLEFIELD, game)) { + if (rnd.nextBoolean()) + activateAbility(ability, game); + } + } + + // don't allow X=0 + if (getManaPool().count() == 0) { + return false; + } + + cost.setPaid(); + return true; + } + + @Override + public int chooseEffect(List rEffects, Game game) { + return rnd.nextInt(rEffects.size()); + } + + @Override + public TriggeredAbility chooseTriggeredAbility(TriggeredAbilities abilities, Game game) { + return abilities.get(rnd.nextInt(abilities.size())); + } + + @Override + public Mode chooseMode(Modes modes, Ability source, Game game) { + Iterator it = modes.values().iterator(); + Mode mode = it.next(); + if (modes.size() == 1) + return mode; + int modeNum = rnd.nextInt(modes.values().size()); + for (int i = 0; i < modeNum; i++) { + mode = it.next(); + } + return mode; + } + + @Override + public UUID chooseAttackerOrder(List attackers, Game game) { + return attackers.get(rnd.nextInt(attackers.size())).getId(); + } + + @Override + public UUID chooseBlockerOrder(List blockers, Game game) { + return blockers.get(rnd.nextInt(blockers.size())).getId(); + } + + @Override + public void assignDamage(int damage, List targets, String singleTargetName, UUID sourceId, Game game) { + int remainingDamage = damage; + UUID targetId; + int amount; + while (remainingDamage > 0) { + if (targets.size() == 1) { + targetId = targets.get(0); + amount = remainingDamage; + } + else { + targetId = targets.get(rnd.nextInt(targets.size())); + amount = rnd.nextInt(damage + 1); + } + Permanent permanent = game.getPermanent(targetId); + if (permanent != null) { + permanent.damage(amount, sourceId, game, true, false); + remainingDamage -= amount; + } + else { + Player player = game.getPlayer(targetId); + if (player != null) { + player.damage(amount, sourceId, game, false, true); + remainingDamage -= amount; + } + } + targets.remove(targetId); + } + } + + @Override + public int getAmount(int min, int max, String message, Game game) { + return rnd.nextInt(max - min) + min; + } + +} diff --git a/Mage.Server.Plugins/pom.xml b/Mage.Server.Plugins/pom.xml index 0cd409d3078..bfebc630552 100644 --- a/Mage.Server.Plugins/pom.xml +++ b/Mage.Server.Plugins/pom.xml @@ -22,6 +22,7 @@ Mage.Player.AI Mage.Player.AIMinimax Mage.Player.AI.MA + Mage.Player.AIMCTS Mage.Player.Human Mage.Tournament.BoosterDraft Mage.Tournament.Sealed diff --git a/Mage.Server/config/config.xml b/Mage.Server/config/config.xml index 0af9337b3ab..3703d1642ae 100644 --- a/Mage.Server/config/config.xml +++ b/Mage.Server/config/config.xml @@ -6,6 +6,7 @@ + diff --git a/Mage.Server/plugins/mage-player-aimcts.jar b/Mage.Server/plugins/mage-player-aimcts.jar new file mode 100644 index 0000000000000000000000000000000000000000..6d732f5da10c9f476b67c3caf294c82e0051e168 GIT binary patch literal 22793 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 literal 0 HcmV?d00001