diff --git a/.gitattributes b/.gitattributes index 30a993ec2f6..5ba123b8660 100644 --- a/.gitattributes +++ b/.gitattributes @@ -147,6 +147,11 @@ forge-ai/src/main/java/forge/ai/ability/UntapAi.java -text forge-ai/src/main/java/forge/ai/ability/UntapAllAi.java -text forge-ai/src/main/java/forge/ai/ability/VoteAi.java -text forge-ai/src/main/java/forge/ai/ability/ZoneExchangeAi.java -text +forge-ai/src/main/java/simulation/GameCopier.java -text +forge-ai/src/main/java/simulation/GameSimulator.java -text +forge-ai/src/main/java/simulation/GameStateEvaluator.java -text +forge-ai/src/main/java/simulation/PossibleTargetSelector.java -text +forge-ai/src/main/java/simulation/SpellAbilityPicker.java -text forge-core/.classpath -text forge-core/.project -text forge-core/.settings/org.eclipse.core.resources.prefs -text diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 1792dd76ff8..cd2ab46dae2 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -28,6 +28,8 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import simulation.SpellAbilityPicker; + import com.esotericsoftware.minlog.Log; import com.google.common.base.Function; import com.google.common.base.Predicate; @@ -102,6 +104,7 @@ public class AiController { private final Game game; private final AiCardMemory memory; public boolean bCheatShuffle; + private SpellAbilityPicker simPicker; public boolean canCheatShuffle() { return bCheatShuffle; @@ -127,6 +130,7 @@ public class AiController { player = computerPlayer; game = game0; memory = new AiCardMemory(); + simPicker = new SpellAbilityPicker(game, player); } private CardCollection getAvailableCards() { @@ -1206,7 +1210,11 @@ public class AiController { private SpellAbility chooseSpellAbilityToPlay(final ArrayList all, boolean skipCounter) { if (all == null || all.isEmpty()) return null; - + + SpellAbility simSa = simPicker.chooseSpellAbilityToPlay(getOriginalAndAltCostAbilities(all), skipCounter); + if (simSa != null) + return simSa; + Collections.sort(all, saComparator); // put best spells first for (final SpellAbility sa : getOriginalAndAltCostAbilities(all)) { diff --git a/forge-ai/src/main/java/simulation/GameCopier.java b/forge-ai/src/main/java/simulation/GameCopier.java new file mode 100644 index 00000000000..d60398405f2 --- /dev/null +++ b/forge-ai/src/main/java/simulation/GameCopier.java @@ -0,0 +1,195 @@ +package simulation; + +import java.util.*; +import java.util.Map.Entry; + +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; + +import forge.card.CardStateName; +import forge.game.Game; +import forge.game.GameObject; +import forge.game.GameRules; +import forge.game.Match; +import forge.game.card.Card; +import forge.game.card.CardFactory; +import forge.game.card.CardFactoryUtil; +import forge.game.card.CounterType; +import forge.game.player.Player; +import forge.game.player.RegisteredPlayer; +import forge.game.trigger.TriggerType; +import forge.game.zone.ZoneType; + +public class GameCopier { + private static final ZoneType[] ZONES = new ZoneType[] { + ZoneType.Battlefield, + ZoneType.Hand, + ZoneType.Graveyard, + ZoneType.Library, + ZoneType.Exile, + }; + + private Game origGame; + private BiMap playerMap = HashBiMap.create(); + private BiMap cardMap = HashBiMap.create(); + + public GameCopier(Game origGame) { + this.origGame = origGame; + } + + public Game makeCopy() { + List origPlayers = origGame.getMatch().getPlayers(); + List newPlayers = new ArrayList<>(); + for (RegisteredPlayer p : origPlayers) { + newPlayers.add(clonePlayer(p)); + } + GameRules currentRules = origGame.getRules(); + Match newMatch = new Match(currentRules, newPlayers); + Game newGame = new Game(newPlayers, currentRules, newMatch); + for (int i = 0; i < origGame.getPlayers().size(); i++) { + Player origPlayer = origGame.getPlayers().get(i); + Player newPlayer = newGame.getPlayers().get(i); + newPlayer.setLife(origPlayer.getLife(), null); + newPlayer.setActivateLoyaltyAbilityThisTurn(origPlayer.getActivateLoyaltyAbilityThisTurn()); + newPlayer.setPoisonCounters(origPlayer.getPoisonCounters(), null); + newPlayer.setLifeLostLastTurn(origPlayer.getLifeLostLastTurn()); + newPlayer.setLifeLostThisTurn(origPlayer.getLifeLostThisTurn()); + newPlayer.setPreventNextDamage(origPlayer.getPreventNextDamage()); + playerMap.put(origPlayer, newPlayer); + } + + Player newPlayerTurn = playerMap.get(origGame.getPhaseHandler().getPlayerTurn()); + newGame.getPhaseHandler().devModeSet(origGame.getPhaseHandler().getPhase(), newPlayerTurn); + newGame.getTriggerHandler().suppressMode(TriggerType.ChangesZone); + + copyGameState(newGame); + + newGame.getTriggerHandler().clearSuppression(TriggerType.ChangesZone); + newGame.getAction().checkStateEffects(true); //ensure state based effects and triggers are updated + + return newGame; + } + + private RegisteredPlayer clonePlayer(RegisteredPlayer p) { + RegisteredPlayer clone = new RegisteredPlayer(p.getDeck()); + clone.setPlayer(p.getPlayer()); + return clone; + } + + private void copyGameState(Game newGame) { + for (ZoneType zone : ZONES) { + for (Card card : origGame.getCardsIn(zone)) { + addCard(newGame, zone, card); + } + } + for (Card card : origGame.getCardsIn(ZoneType.Battlefield)) { + Card otherCard = cardMap.get(card); + if (card.isEnchanting()) { + otherCard.setEnchanting(cardMap.get(card.getEnchanting())); + } + if (card.isEquipping()) { + otherCard.setEquipping(cardMap.get(card.getEquipping())); + } + if (card.isFortifying()) { + otherCard.setFortifying(cardMap.get(card.getFortifying())); + } + if (card.getCloneOrigin() != null) { + otherCard.setCloneOrigin(cardMap.get(card.getCloneOrigin())); + } + if (card.getHaunting() != null) { + otherCard.setHaunting(cardMap.get(card.getHaunting())); + } + if (card.getEffectSource() != null) { + otherCard.setEffectSource(cardMap.get(card.getEffectSource())); + } + if (card.isPaired()) { + otherCard.setPairedWith(cardMap.get(card.getPairedWith())); + } + otherCard.setCommander(card.isCommander()); + // TODO: Verify that the above relationships are preserved bi-directionally or not. + } + } + + @SuppressWarnings("unchecked") + private void addCard(Game newGame, ZoneType zone, Card c) { + Player owner = playerMap.get(c.getOwner()); + Card newCard = null; + if (c.isToken()) { + String tokenStr = new CardFactory.TokenInfo(c).toString(); + // TODO: Use a version of the API that doesn't return a list (i.e. these shouldn't be affected + // by doubling season, etc). + newCard = CardFactory.makeToken(CardFactory.TokenInfo.fromString(tokenStr), owner).get(0); + } else { + newCard = Card.fromPaperCard(c.getPaperCard(), owner); + } + cardMap.put(c, newCard); + + Player zoneOwner = owner; + if (zone == ZoneType.Battlefield) { + // TODO: Controllers' list with timestamps should be copied. + zoneOwner = playerMap.get(c.getController()); + newCard.setController(zoneOwner, 0); + newCard.addTempPowerBoost(c.getTempPowerBoost()); + newCard.addTempToughnessBoost(c.getTempToughnessBoost()); + + newCard.setChangedCardTypes(c.getChangedCardTypes()); + newCard.setChangedCardKeywords(c.getChangedCardKeywords()); + // TODO: Is this correct? Does it not duplicate keywords from enchantments and such? + for (String kw : c.getHiddenExtrinsicKeywords()) + newCard.addHiddenExtrinsicKeyword(kw); + newCard.setExtrinsicKeyword((ArrayList) c.getExtrinsicKeyword().clone()); + if (c.isTapped()) { + newCard.setTapped(true); + } + if (c.isSick()) { + newCard.setSickness(true); + } + if (c.isFaceDown()) { + newCard.setState(CardStateName.FaceDown, true); + if (c.isManifested()) { + newCard.setManifested(true); + // TODO: Should be able to copy other abilities... + newCard.addSpellAbility(CardFactoryUtil.abilityManifestFaceUp(newCard, newCard.getManaCost())); + } + } + + Map counters = c.getCounters(); + if (!counters.isEmpty()) { + for(Entry kv : counters.entrySet()) { + String str = kv.getKey().toString(); + int count = kv.getValue(); + newCard.addCounter(CounterType.valueOf(str), count, false); + } + } + // TODO: Other chosen things... + if (c.getChosenPlayer() != null) { + newCard.setChosenPlayer(playerMap.get(c.getChosenPlayer())); + } + // TODO: FIXME + if (c.hasRemembered()) { + for (Object o : c.getRemembered()) { + System.out.println("Remembered: " + o + o.getClass()); + //newCard.addRemembered(o); + } + } + } + + zoneOwner.getZone(zone).add(newCard); + } + + + public GameObject find(GameObject o) { + GameObject result = cardMap.get(o); + if (result != null) + return result; + // TODO: Have only one GameObject map? + return playerMap.get(o); + } + public GameObject reverseFind(GameObject o) { + GameObject result = cardMap.inverse().get(o); + if (result != null) + return result; + // TODO: Have only one GameObject map? + return playerMap.inverse().get(o); + } +} diff --git a/forge-ai/src/main/java/simulation/GameSimulator.java b/forge-ai/src/main/java/simulation/GameSimulator.java new file mode 100644 index 00000000000..c04fbc3f1fd --- /dev/null +++ b/forge-ai/src/main/java/simulation/GameSimulator.java @@ -0,0 +1,151 @@ +package simulation; + +import java.util.HashSet; +import java.util.Set; + +import forge.ai.ComputerUtil; +import forge.ai.PlayerControllerAi; +import forge.game.Game; +import forge.game.GameObject; +import forge.game.card.Card; +import forge.game.player.Player; +import forge.game.spellability.Ability; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.TargetChoices; +import forge.game.zone.ZoneType; + +public class GameSimulator { + private Game origGame; + private GameCopier copier; + private Game simGame; + private Player aiPlayer; + private Player opponent; + private GameStateEvaluator eval; + + public GameSimulator(final Game origGame) { + this.origGame = origGame; + copier = new GameCopier(origGame); + simGame = copier.makeCopy(); + // TODO: + aiPlayer = simGame.getPlayers().get(1); + opponent = simGame.getPlayers().get(0); + eval = new GameStateEvaluator(); + int simScore = eval.getScoreForGameState(simGame, aiPlayer, opponent); + int origScore = getScoreForOrigGame(); + debugPrint = true; + if (simScore != origScore) { + // Print debug info. + eval.getScoreForGameState(simGame, aiPlayer, opponent); + getScoreForOrigGame(); + throw new RuntimeException("Game copy error"); + } + } + + public static boolean debugPrint; + public static void debugPrint(String str) { + if (debugPrint) { + System.out.println(str); + } + } + + private SpellAbility findSaInSimGame(SpellAbility sa) { + Card origHostCard = sa.getHostCard(); + ZoneType zone = origHostCard.getZone().getZoneType(); + for (Card c : simGame.getCardsIn(zone)) { + if (!c.getOwner().getController().isAI()) { + continue; + } + debugPrint(c.getName()+"->"); + if (c.getName().equals(origHostCard.getName())) { + for (SpellAbility cSa : c.getSpellAbilities()) { + debugPrint(" "+cSa); + if (cSa.getDescription().equals(sa.getDescription())) { + return cSa; + } + } + } + } + return null; + } + + + public int simulateSpellAbility(SpellAbility origSa) { + // TODO: optimize: prune identical SA (e.g. two of the same card in hand) + + boolean found = false; + SpellAbility sa = findSaInSimGame(origSa); + if (sa != null) { + found = true; + } else { + System.err.println("SA not found! " + sa); + return Integer.MIN_VALUE; + } + + Player origActivatingPlayer = sa.getActivatingPlayer(); + sa.setActivatingPlayer(aiPlayer); + if (origSa.usesTargeting()) { + for (GameObject o : origSa.getTargets().getTargets()) { + debugPrint("Copying over target " +o); + debugPrint(" found: "+copier.find(o)); + sa.getTargets().add(copier.find(o)); + } + } + + debugPrint("Simulating playing sa: " + sa + " found="+found); + if (sa == Ability.PLAY_LAND_SURROGATE) { + aiPlayer.playLand(sa.getHostCard(), false); + } + else { + // TODO: should simulate all possible targets... + if (!sa.getAllTargetChoices().isEmpty()) { + debugPrint("Targets: "); + for (TargetChoices target : sa.getAllTargetChoices()) { + System.out.print(target.getTargetedString()); + } + System.out.println(); + } + ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame); + } + + if (simGame.getStack().isEmpty()) { + System.err.println("Stack empty: " + sa); + return Integer.MIN_VALUE; + } + opponent.runWithController(new Runnable() { + @Override + public void run() { + final Set allAffectedCards = new HashSet(); + do { + // Resolve the top effect on the stack. + simGame.getStack().resolveStack(); + // Evaluate state based effects as a result of resolving stack. + // Note: Needs to happen after resolve stack rather than at the + // top of the loop to ensure state effects are evaluated after the + // last resolved effect + simGame.getAction().checkStateEffects(false, allAffectedCards); + // Add any triggers as a result of resolving the effect. + simGame.getStack().addAllTriggeredAbilitiesToStack(); + // Continue until stack is empty. + } while (!simGame.getStack().isEmpty() && !simGame.isGameOver()); + } + }, new PlayerControllerAi(simGame, opponent, opponent.getLobbyPlayer())); + + // TODO: If this is during combat, before blockers are declared, + // we should simulate how combat will resolve and evaluate that + // state instead! + debugPrint("SimGame:"); + int score = eval.getScoreForGameState(simGame, aiPlayer, opponent); + + sa.setActivatingPlayer(origActivatingPlayer); + + return score; + } + + public int getScoreForOrigGame() { + // TODO: Make this logic more bulletproof. + Player origAiPlayer = origGame.getPlayers().get(1); + Player origOpponent = origGame.getPlayers().get(0); + return eval.getScoreForGameState(origGame, origAiPlayer, origOpponent); + } + +} diff --git a/forge-ai/src/main/java/simulation/GameStateEvaluator.java b/forge-ai/src/main/java/simulation/GameStateEvaluator.java new file mode 100644 index 00000000000..cc9fae93a57 --- /dev/null +++ b/forge-ai/src/main/java/simulation/GameStateEvaluator.java @@ -0,0 +1,73 @@ +package simulation; + +import forge.ai.ComputerUtilCard; +import forge.game.Game; +import forge.game.card.Card; +import forge.game.player.Player; +import forge.game.zone.ZoneType; + +public class GameStateEvaluator { + + public int getScoreForGameState(Game game, Player aiPlayer, Player opponent) { + if (game.isGameOver()) { + return game.getOutcome().getWinningPlayer() == aiPlayer ? Integer.MAX_VALUE : Integer.MIN_VALUE; + } + int score = 0; + // TODO: more than 2 players + int myCards = 0; + int theirCards = 0; + for (Card c : game.getCardsIn(ZoneType.Hand)) { + if (c.getController() == aiPlayer) { + myCards++; + } else { + theirCards++; + } + } + GameSimulator.debugPrint("My cards in hand: " + myCards); + GameSimulator.debugPrint("Their cards in hand: " + theirCards); + score += 3 * myCards - 3 * theirCards; + for (Card c : game.getCardsIn(ZoneType.Battlefield)) { + int value = evalCard(c); + String str = c.toString(); + if (c.isCreature()) { + str += " " + c.getNetPower() + "/" + c.getNetToughness(); + } + if (c.getController() == aiPlayer) { + GameSimulator.debugPrint(" Battlefield: " + str + " = " + value); + score += value; + } else { + GameSimulator.debugPrint(" Battlefield: " + str + " = -" + value); + score -= value; + } + String nonAbilityText = c.getNonAbilityText(); + if (!nonAbilityText.isEmpty()) { + GameSimulator.debugPrint(" "+nonAbilityText.replaceAll("CARDNAME", c.getName())); + } + + + } + GameSimulator.debugPrint(" My life: " + aiPlayer.getLife()); + score += aiPlayer.getLife(); + GameSimulator.debugPrint(" Opponent life: -" + opponent.getLife()); + score -= opponent.getLife(); + GameSimulator.debugPrint("Score = " + score); + return score; + } + + private static int evalCard(Card c) { + // TODO: These should be based on other considerations - e.g. in relation to opponents state. + if (c.isCreature()) { + return ComputerUtilCard.evaluateCreature(c); + } else if (c.isLand()) { + return 100; + } else if (c.isEnchantingCard()) { + // TODO: Should provide value in whatever it's enchanting? + // Else the computer would think that casting a Lifelink enchantment + // on something that already has lifelink is a net win. + return 0; + } else { + // e.g. a 5 CMC permanent results in 200, whereas a 5/5 creature is ~225 + return 50 + 30 * c.getCMC(); + } + } +} diff --git a/forge-ai/src/main/java/simulation/PossibleTargetSelector.java b/forge-ai/src/main/java/simulation/PossibleTargetSelector.java new file mode 100644 index 00000000000..a17db47be3f --- /dev/null +++ b/forge-ai/src/main/java/simulation/PossibleTargetSelector.java @@ -0,0 +1,50 @@ +package simulation; + +import java.util.ArrayList; +import java.util.List; + +import forge.game.Game; +import forge.game.GameObject; +import forge.game.card.CardUtil; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.TargetRestrictions; + +public class PossibleTargetSelector { + private SpellAbility sa; + private TargetRestrictions tgt; + private int targetIndex; + private List validTargets; + + public PossibleTargetSelector(Game game, Player self, SpellAbility sa) { + this.sa = sa; + this.tgt = sa.getTargetRestrictions(); + this.targetIndex = 0; + this.validTargets = new ArrayList(); + if (tgt.canTgtPermanent() || tgt.canTgtCreature()) { + // TODO: What about things that target enchantments and such? + validTargets.addAll(CardUtil.getValidCardsToTarget(tgt, sa)); + } + if (tgt.canTgtPlayer()) { + for (Player p : game.getPlayers()) { + if (p != self || !tgt.canOnlyTgtOpponent()) { + validTargets.add(p); + } + } + } + } + + public boolean selectNextTargets() { + if (targetIndex >= validTargets.size()) { + return false; + } + sa.resetTargets(); + int index = targetIndex; + while (sa.getTargets().getNumTargeted() < tgt.getMaxTargets(sa.getHostCard(), sa)) { + sa.getTargets().add(validTargets.get(index++)); + } + // TODO: smarter about multiple targets, identical targets, etc... + targetIndex++; + return true; + } +} diff --git a/forge-ai/src/main/java/simulation/SpellAbilityPicker.java b/forge-ai/src/main/java/simulation/SpellAbilityPicker.java new file mode 100644 index 00000000000..77ba1e59509 --- /dev/null +++ b/forge-ai/src/main/java/simulation/SpellAbilityPicker.java @@ -0,0 +1,109 @@ +package simulation; + +import java.util.ArrayList; + +import forge.ai.AiPlayDecision; +import forge.ai.ComputerUtilCost; +import forge.game.Game; +import forge.game.ability.ApiType; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.TargetChoices; + +public class SpellAbilityPicker { + private static boolean USE_SIMULATION = false; + private Game game; + private Player player; + + public SpellAbilityPicker(Game game, Player player) { + this.game = game; + this.player = player; + } + + public SpellAbility chooseSpellAbilityToPlay(final ArrayList originalAndAltCostAbilities, boolean skipCounter) { + if (!USE_SIMULATION) + return null; + + System.out.println("----\nchooseSpellAbilityToPlay game " + game.toString()); + System.out.println("---- (phase = " + game.getPhaseHandler().getPhase() + ")"); + + ArrayList candidateSAs = new ArrayList<>(); + for (final SpellAbility sa : originalAndAltCostAbilities) { + // Don't add Counterspells to the "normal" playcard lookups + if (skipCounter && sa.getApi() == ApiType.Counter) { + continue; + } + sa.setActivatingPlayer(player); + + AiPlayDecision opinion = canPlayAndPayForSim(sa); + System.out.println(" " + opinion + ": " + sa); + // PhaseHandler ph = game.getPhaseHandler(); + // System.out.printf("Ai thinks '%s' of %s -> %s @ %s %s >>> \n", opinion, sa.getHostCard(), sa, Lang.getPossesive(ph.getPlayerTurn().getName()), ph.getPhase()); + + if (opinion != AiPlayDecision.WillPlay) + continue; + candidateSAs.add(sa); + } + if (candidateSAs.isEmpty()) { + return null; + } + SpellAbility bestSa = null; + System.out.println("Evaluating..."); + GameSimulator simulator = new GameSimulator(game); + // FIXME: This is wasteful, we should re-use the same simulator... + int bestSaValue = simulator.getScoreForOrigGame(); + for (final SpellAbility sa : candidateSAs) { + int value = evaluateSa(sa); + if (value > bestSaValue) { + bestSaValue = value; + bestSa = sa; + } + } + + System.out.println("BEST: " + bestSa + " SCORE: " + bestSaValue); + return bestSa; + } + + private AiPlayDecision canPlayAndPayForSim(final SpellAbility sa) { + if (!sa.canPlay()) { + return AiPlayDecision.CantPlaySa; + } + if (sa.getConditions() != null && !sa.getConditions().areMet(sa)) { + return AiPlayDecision.CantPlaySa; + } + + /* + AiPlayDecision op = canPlaySa(sa); + if (op != AiPlayDecision.WillPlay) { + return op; + } + */ + return ComputerUtilCost.canPayCost(sa, player) ? AiPlayDecision.WillPlay : AiPlayDecision.CantAfford; + } + + private int evaluateSa(SpellAbility sa) { + System.out.println("Evaluate SA: " + sa); + if (!sa.usesTargeting()) { + GameSimulator simulator = new GameSimulator(game); + return simulator.simulateSpellAbility(sa); + } + PossibleTargetSelector selector = new PossibleTargetSelector(game, player, sa); + int bestScore = Integer.MIN_VALUE; + TargetChoices tgt = null; + while (selector.selectNextTargets()) { + System.out.println("Trying targets: " + sa.getTargets().getTargetedString()); + GameSimulator simulator = new GameSimulator(game); + int score = simulator.simulateSpellAbility(sa); + if (score > bestScore) { + bestScore = score; + tgt = sa.getTargets(); + sa.resetTargets(); + } + } + if (tgt != null) { + sa.setTargets(tgt); + } + return bestScore; + } + +} diff --git a/forge-game/src/main/java/forge/game/card/Card.java b/forge-game/src/main/java/forge/game/card/Card.java index 8800f5e50d0..effb796dd8b 100644 --- a/forge-game/src/main/java/forge/game/card/Card.java +++ b/forge-game/src/main/java/forge/game/card/Card.java @@ -2396,6 +2396,10 @@ public class Card extends GameEntity implements Comparable { public Map getChangedCardTypes() { return Collections.unmodifiableMap(changedCardTypes); } + + public Map getChangedCardKeywords() { + return changedCardKeywords; + } public final void addChangedCardTypes(final CardType addType, final CardType removeType, final boolean removeSuperTypes, final boolean removeCardTypes, final boolean removeSubTypes, @@ -6443,4 +6447,18 @@ public class Card extends GameEntity implements Comparable { return keywords; } } + + public void setChangedCardTypes(Map changedCardTypes) { + this.changedCardTypes.clear(); + for (Entry entry : changedCardTypes.entrySet()) { + this.changedCardTypes.put(entry.getKey(), entry.getValue()); + } + } + + public void setChangedCardKeywords(Map changedCardKeywords) { + this.changedCardKeywords.clear(); + for (Entry entry : changedCardKeywords.entrySet()) { + this.changedCardKeywords.put(entry.getKey(), entry.getValue()); + } + } }