diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 3040054fee1..5c5f3d5bb5a 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -24,6 +24,7 @@ import forge.ai.ability.ChangeZoneAi; import forge.ai.ability.LearnAi; import forge.ai.simulation.GameStateEvaluator; +import forge.ai.simulation.OnePlaySafetyChecker; import forge.ai.simulation.SpellAbilityPicker; import forge.card.CardStateName; import forge.card.CardType; @@ -95,6 +96,7 @@ public class AiController { private Combat predictedCombatNextTurn; private boolean useSimulation; private SpellAbilityPicker simPicker; + private OnePlaySafetyChecker safetyChecker; private int lastAttackAggression; private boolean useLivingEnd; private List skipped; @@ -105,6 +107,7 @@ public AiController(final Player computerPlayer, final Game game0) { game = game0; memory = new AiCardMemory(); simPicker = new SpellAbilityPicker(game, player); + safetyChecker = new OnePlaySafetyChecker(player); } public boolean usesSimulation() { @@ -131,6 +134,14 @@ public Player getPlayer() { return player; } + public int getSafetyThreatBonus(Card card) { + return safetyChecker.getThreatAssessmentBonus(card); + } + + public AiPlayDecision checkOnePlaySafety(SpellAbility sa) { + return safetyChecker.check(sa); + } + public AiCardMemory getCardMemory() { return memory; } @@ -879,6 +890,13 @@ private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) { return AiPlayDecision.CantAfford; } + if (!useSimulation && !OnePlaySafetyChecker.isChecking()) { + AiPlayDecision safety = safetyChecker.checkDuringSpellSelection(sa); + if (safety != AiPlayDecision.WillPlay) { + return safety; + } + } + return AiPlayDecision.WillPlay; } @@ -1274,6 +1292,13 @@ public boolean getBoolProperty(AiProps propName) { } public AiPlayDecision canPlayFromEffectAI(Spell spell, boolean mandatory, boolean withoutPayingManaCost) { + if (!mandatory && !OnePlaySafetyChecker.isChecking()) { + AiPlayDecision safety = safetyChecker.checkStatic(spell); + if (safety != AiPlayDecision.WillPlay) { + return safety; + } + } + if (spell instanceof SpellApiBased) { boolean chance; if (withoutPayingManaCost) { diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index 5c558e8d3ce..38b440a6360 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -81,6 +81,10 @@ public static Card getMostExpensivePermanentAI(final CardCollectionView list, fi if (targeted) { all = CardLists.filter(all, c -> c.canBeTargetedBy(spell)); } + Card safetyThreat = getKnownSafetyThreatToRemove(spell == null ? null : spell.getActivatingPlayer(), all); + if (safetyThreat != null) { + return safetyThreat; + } return getMostExpensivePermanentAI(all); } @@ -1221,6 +1225,11 @@ public static boolean useRemovalNow(final SpellAbility sa, final Card c, final i return true; } + final int safetyThreatBonus = getSafetyThreatBonus(ai, c); + if (safetyThreatBonus >= 150) { + return true; + } + //Check for cards that profit from spells - for example Prowess or Threshold if (phaseType == PhaseType.MAIN1 && ComputerUtil.castSpellInMain1(ai, sa)) { return true; @@ -1413,6 +1422,7 @@ public static boolean useRemovalNow(final SpellAbility sa, final Card c, final i } //TODO:add threat from triggers and other abilities (ie. Bident of Thassa) } + threat += safetyThreatBonus / 100.0f; if (!c.getManaAbilities().isEmpty()) { threat += 0.5f * costTarget / opp.getLandsInPlay().size(); //set back opponent's mana } @@ -1425,6 +1435,29 @@ public static boolean useRemovalNow(final SpellAbility sa, final Card c, final i return chance < valueNow; } + public static Card getKnownSafetyThreatToRemove(final Player ai, final Iterable cards) { + if (ai == null) { + return null; + } + Card best = null; + int bestBonus = 0; + for (Card card : cards) { + int bonus = getSafetyThreatBonus(ai, card); + if (bonus > bestBonus) { + best = card; + bestBonus = bonus; + } + } + return best; + } + + private static int getSafetyThreatBonus(final Player ai, final Card card) { + if (!(ai.getController() instanceof PlayerControllerAi)) { + return 0; + } + return ((PlayerControllerAi) ai.getController()).getAi().getSafetyThreatBonus(card); + } + /** * Decides if the "pump" is worthwhile * diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java index 00087d79da4..e486e45d56c 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAllAi.java @@ -68,7 +68,7 @@ protected AiAbilityDecision canPlay(Player ai, SpellAbility sa) { if ("LivingDeath".equals(aiLogic)) { return SpecialCardAi.LivingDeath.consider(ai, sa); - } else if ("Timetwister".equals(aiLogic)) { + } else if ("Timetwister".equalsIgnoreCase(aiLogic)) { return SpecialCardAi.Timetwister.consider(ai, sa); } else if ("RetDiscardedThisTurn".equals(aiLogic)) { boolean result = !ai.getDiscardedThisTurn().isEmpty() && ai.getGame().getPhaseHandler().is(PhaseType.END_OF_TURN); diff --git a/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java b/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java index 146db23a4d0..63b8fbc6eba 100644 --- a/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/DestroyAi.java @@ -216,9 +216,9 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa } } - Card choice = null; + Card choice = ComputerUtilCard.getKnownSafetyThreatToRemove(ai, list); // If the targets are only of one type, take the best - if (CardLists.getNotType(list, "Creature").isEmpty()) { + if (choice == null && CardLists.getNotType(list, "Creature").isEmpty()) { choice = ComputerUtilCard.getBestCreatureAI(list); if ("OppDestroyYours".equals(logic)) { Card aiBest = ComputerUtilCard.getBestCreatureAI(ai.getCreaturesInPlay()); @@ -226,7 +226,7 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi); } } - } else if (CardLists.getNotType(list, "Land").isEmpty()) { + } else if (choice == null && CardLists.getNotType(list, "Land").isEmpty()) { choice = ComputerUtilCard.getBestLandToRemoveAI(ai, list, sa); if ("LandForLand".equals(logic) || "GhostQuarter".equals(logic)) { @@ -235,7 +235,7 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi); } } - } else { + } else if (choice == null) { // TODO look for "exiled until leaves" of own stuff choice = ComputerUtilCard.getMostExpensivePermanentAI(list); } diff --git a/forge-ai/src/main/java/forge/ai/ability/DestroyAllAi.java b/forge-ai/src/main/java/forge/ai/ability/DestroyAllAi.java index 242e5c22ba2..cf9d5cdf30c 100644 --- a/forge-ai/src/main/java/forge/ai/ability/DestroyAllAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/DestroyAllAi.java @@ -1,8 +1,22 @@ package forge.ai.ability; -import forge.ai.*; +import java.util.function.Predicate; + +import forge.ai.AiAbilityDecision; +import forge.ai.AiBlockController; +import forge.ai.AiPlayDecision; +import forge.ai.ComputerUtilCard; +import forge.ai.ComputerUtilCombat; +import forge.ai.ComputerUtilCost; +import forge.ai.SpecialCardAi; +import forge.ai.SpellAbilityAi; +import forge.ai.simulation.SwingyPlaySimulationEvaluator; import forge.card.MagicColor; -import forge.game.card.*; +import forge.game.card.Card; +import forge.game.card.CardCollection; +import forge.game.card.CardLists; +import forge.game.card.CardPredicates; +import forge.game.card.CounterEnumType; import forge.game.combat.Combat; import forge.game.cost.Cost; import forge.game.cost.CostDamage; @@ -13,8 +27,6 @@ import forge.game.zone.ZoneType; import forge.util.collect.FCollectionView; -import java.util.function.Predicate; - public class DestroyAllAi extends SpellAbilityAi { private static final Predicate predicate = c -> !(c.hasKeyword(Keyword.INDESTRUCTIBLE) || c.getCounters(CounterEnumType.SHIELD) > 0 || c.hasSVar("SacMe")); @@ -66,6 +78,11 @@ public static AiAbilityDecision doMassRemovalLogic(Player ai, SpellAbility sa) { ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger()); } + AiAbilityDecision simulatedDecision = SwingyPlaySimulationEvaluator.judge(ai, sa); + if (simulatedDecision != null) { + return simulatedDecision; + } + // TODO should probably sort results when targeted to use on biggest threat instead of first match for (Player opponent: ai.getOpponents()) { CardCollection opplist = CardLists.getValidCards(opponent.getCardsIn(ZoneType.Battlefield), valid, source.getController(), source, sa); diff --git a/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java b/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java index a367ab10d3d..291bf7c9966 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java +++ b/forge-ai/src/main/java/forge/ai/simulation/GameCopier.java @@ -175,7 +175,7 @@ public Game makeCopy(PhaseType advanceToPhase, Player aiPlayer) { // TODO update thisTurnCast if (advanceToPhase != null) { - newGame.getPhaseHandler().devAdvanceToPhase(advanceToPhase, () -> GameSimulator.resolveStack(newGame, aiPlayer.getWeakestOpponent())); + newGame.getPhaseHandler().devAdvanceToPhase(advanceToPhase, () -> GameSimulator.resolveStack(newGame, aiPlayer)); } return newGame; 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 aac320753a8..32846e62118 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java +++ b/forge-ai/src/main/java/forge/ai/simulation/GameSimulator.java @@ -53,7 +53,7 @@ public GameSimulator(SimulationController controller, Game origGame, Player orig debugLines = origLines; Game copyOrigGame = copier.makeCopy(); Player copyOrigAiPlayer = copyOrigGame.getPlayers().get(1); - resolveStack(copyOrigGame, copyOrigGame.getPlayers().get(0)); + resolveStack(copyOrigGame, copyOrigAiPlayer); origScore = eval.getScoreForGameState(copyOrigGame, copyOrigAiPlayer); } @@ -225,9 +225,7 @@ public Score simulateSpellAbility(SpellAbility origSa, GameStateEvaluator eval, } if (resolve) { - // TODO: Support multiple opponents. - Player opponent = aiPlayer.getWeakestOpponent(); - resolveStack(simGame, opponent); + resolveStack(simGame, aiPlayer); } // TODO: If this is during combat, before blockers are declared, @@ -260,11 +258,9 @@ public Score simulateSpellAbility(SpellAbility origSa, GameStateEvaluator eval, return score; } - public static void resolveStack(final Game game, final Player opponent) { - // TODO: This needs to set an AI controller for all opponents, in case of multiplayer. - PlayerControllerAi sim = new PlayerControllerAi(game, opponent, opponent.getLobbyPlayer()); - sim.setUseSimulation(true); - opponent.runWithController(() -> { + public static void resolveStack(final Game game, final Player preservedPlayer) { + List players = new ArrayList<>(game.getPlayers()); + runWithSimulationControllers(game, preservedPlayer, players, 0, () -> { final Set allAffectedCards = new HashSet<>(); game.getAction().checkStateEffects(false, allAffectedCards); game.getStack().addAllTriggeredAbilitiesToStack(); @@ -288,7 +284,24 @@ public static void resolveStack(final Game game, final Player opponent) { // Continue until stack is empty. } - }, sim); + }); + } + + private static void runWithSimulationControllers(final Game game, final Player preservedPlayer, + final List players, final int index, final Runnable proc) { + if (index >= players.size()) { + proc.run(); + return; + } + + Player player = players.get(index); + if (player.equals(preservedPlayer)) { + runWithSimulationControllers(game, preservedPlayer, players, index + 1, proc); + return; + } + PlayerControllerAi sim = new PlayerControllerAi(game, player, player.getLobbyPlayer()); + sim.setUseSimulation(true); + player.runWithController(() -> runWithSimulationControllers(game, preservedPlayer, players, index + 1, proc), sim); } public Game getSimulatedGameState() { 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 979c61052fc..720f701c0f2 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java +++ b/forge-ai/src/main/java/forge/ai/simulation/GameStateEvaluator.java @@ -59,7 +59,7 @@ private CombatSimResult simulateUpcomingCombatThisTurn(final Game evalGame, fina gameCopy = copier.makeCopy(null, aiPlayer); } - gameCopy.getPhaseHandler().devAdvanceToPhase(PhaseType.COMBAT_DAMAGE, () -> GameSimulator.resolveStack(gameCopy, aiPlayer.getWeakestOpponent())); + gameCopy.getPhaseHandler().devAdvanceToPhase(PhaseType.COMBAT_DAMAGE, () -> GameSimulator.resolveStack(gameCopy, aiPlayer)); CombatSimResult result = new CombatSimResult(); result.copier = copier; result.gameCopy = gameCopy; diff --git a/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java b/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java new file mode 100644 index 00000000000..66502f61da8 --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java @@ -0,0 +1,531 @@ +package forge.ai.simulation; + +import forge.ai.AiPlayDecision; +import forge.ai.simulation.GameStateEvaluator.Score; +import forge.game.Game; +import forge.game.GameObject; +import forge.game.IIdentifiable; +import forge.game.ability.ApiType; +import forge.game.ability.AbilityUtils; +import forge.game.card.Card; +import forge.game.phase.PhaseType; +import forge.game.player.Player; +import forge.game.spellability.AbilityStatic; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.TargetChoices; +import forge.game.trigger.Trigger; +import forge.game.trigger.TriggerType; +import forge.game.zone.ZoneType; +import forge.util.IHasForgeLog; + +import java.util.HashMap; +import java.util.Map; + +public class OnePlaySafetyChecker implements IHasForgeLog { + private static final int CATASTROPHIC_SCORE_LOSS = 250; + private static final int BAD_SCORE_LOSS = 50; + private static final int DANGEROUS_LIFE_TOTAL = 5; + private static final ThreadLocal CHECKING = ThreadLocal.withInitial(() -> false); + + private final Player player; + private final Map cache = new HashMap<>(); + private final SafetyThreatMemory safetyThreatMemory; + + public OnePlaySafetyChecker(Player player) { + this.player = player; + this.safetyThreatMemory = new SafetyThreatMemory(player); + } + + public static boolean isChecking() { + return CHECKING.get(); + } + + public AiPlayDecision checkStatic(SpellAbility sa) { + if (isOwnCommanderSpell(sa)) { + return AiPlayDecision.WillPlay; + } + if (isStaticallyUnsafe(sa)) { + safetyThreatMemory.rememberSuspectsForUnsafeAction(sa); + return AiPlayDecision.CurseEffects; + } + return AiPlayDecision.WillPlay; + } + + public AiPlayDecision checkDuringSpellSelection(SpellAbility sa) { + if (CHECKING.get()) { + return AiPlayDecision.WillPlay; + } + if (isOwnCommanderSpell(sa)) { + return AiPlayDecision.WillPlay; + } + + final String key = makeCacheKey(sa); + AiPlayDecision cached = cache.get(key); + if (cached != null) { + return cached; + } + + if (isStaticallyUnsafe(sa)) { + safetyThreatMemory.rememberSuspectsForUnsafeAction(sa); + cache.put(key, AiPlayDecision.CurseEffects); + return AiPlayDecision.CurseEffects; + } + if (!shouldSimulateDuringSpellSelection(sa)) { + return AiPlayDecision.WillPlay; + } + return check(sa); + } + + public AiPlayDecision check(SpellAbility sa) { + if (CHECKING.get()) { + return AiPlayDecision.WillPlay; + } + if (isOwnCommanderSpell(sa)) { + return AiPlayDecision.WillPlay; + } + + final String key = makeCacheKey(sa); + AiPlayDecision cached = cache.get(key); + if (cached != null) { + return cached; + } + + if (isStaticallyUnsafe(sa)) { + safetyThreatMemory.rememberSuspectsForUnsafeAction(sa); + cache.put(key, AiPlayDecision.CurseEffects); + return AiPlayDecision.CurseEffects; + } + if (sa instanceof AbilityStatic) { + return AiPlayDecision.WillPlay; + } + if (sa.getApi() == ApiType.Charm) { + return AiPlayDecision.WillPlay; + } + if (needsTargetsButHasNone(sa)) { + return AiPlayDecision.WillPlay; + } + + CHECKING.set(true); + AiPlayDecision result = AiPlayDecision.WillPlay; + try { + SimulationController controller = new SimulationController(new Score(0)) { + @Override + public boolean shouldRecurse() { + return false; + } + }; + GameSimulator simulator = new GameSimulator(controller, player.getGame(), player, null); + Score origScore = simulator.getScoreForOrigGame(); + Score resultScore = simulator.simulateSpellAbility(sa); + if (isUnsafeResult(sa, simulator, origScore, resultScore)) { + result = AiPlayDecision.CurseEffects; + safetyThreatMemory.rememberSuspectsForUnsafeAction(sa); + } + } catch (RuntimeException ex) { + logSimulationFailure(sa, ex); + result = AiPlayDecision.WillPlay; + } finally { + CHECKING.set(false); + } + + cache.put(key, result); + return result; + } + + public int getThreatAssessmentBonus(Card card) { + return safetyThreatMemory.getThreatAssessmentBonus(card); + } + + private boolean isOwnCommanderSpell(SpellAbility sa) { + Card host = sa.getHostCard(); + return sa.isSpell() && host != null && host.isCommander() && host.getOwner().equals(player); + } + + private void logSimulationFailure(SpellAbility sa, RuntimeException ex) { + Game game = player.getGame(); + aiLog.warn(ex, "OnePlaySafetyChecker simulation failure: player={}, phase={}, turn={}, host={}, api={}, ability={}, targets={}", + player, + game.getPhaseHandler().getPhase(), + game.getPhaseHandler().getPlayerTurn(), + sa.getHostCard(), + sa.getApi(), + sa, + makeTargetsFingerprint(sa)); + } + + private boolean isStaticallyUnsafe(SpellAbility sa) { + if (hasActiveOpponentDrawDanger(player.getGame()) && drawsMultipleCardsForPlayer(sa)) { + return true; + } + if (hasLatentOpponentDrawDanger(player.getGame())) { + if (sa.isSpell() && hasSelfSpellCastDrawEngine(player)) { + return true; + } + if (sa.isSpell() && hasSelfSpellCastDrawTrigger(sa.getHostCard())) { + return true; + } + if (sa.isSpell() && hasSelfTurnDrawTrigger(sa.getHostCard())) { + return true; + } + } + return false; + } + + private boolean needsTargetsButHasNone(SpellAbility sa) { + SpellAbility current = sa; + while (current != null) { + if (current.usesTargeting()) { + TargetChoices targets = current.getTargets(); + if (targets == null || targets.isEmpty()) { + return true; + } + } + current = current.getSubAbility(); + } + return false; + } + + private boolean shouldSimulateDuringSpellSelection(SpellAbility sa) { + if (sa instanceof AbilityStatic) { + return false; + } + if (sa.getApi() == ApiType.Charm) { + return false; + } + if (needsTargetsButHasNone(sa)) { + return false; + } + if (hasActiveOpponentDrawDanger(player.getGame()) && hasApi(sa, ApiType.Draw)) { + return true; + } + if (hasMassBoardChangingApi(sa)) { + return true; + } + return hasPotentialOpponentSafetyThreat() && hasReactiveApi(sa); + } + + private boolean hasPotentialOpponentSafetyThreat() { + for (Card card : player.getGame().getCardsIn(ZoneType.Battlefield)) { + if (card.getController().isOpponentOf(player) && SafetyThreatMemory.isPotentialSafetyThreat(card)) { + return true; + } + } + return false; + } + + private boolean hasApi(SpellAbility sa, ApiType api) { + SpellAbility current = sa; + while (current != null) { + if (current.getApi() == api) { + return true; + } + current = current.getSubAbility(); + } + return false; + } + + private boolean hasMassBoardChangingApi(SpellAbility sa) { + SpellAbility current = sa; + while (current != null) { + ApiType api = current.getApi(); + if (api == ApiType.DestroyAll + || api == ApiType.ChangeZoneAll + || api == ApiType.DamageAll + || api == ApiType.EachDamage + || api == ApiType.SacrificeAll + || api == ApiType.Balance) { + return true; + } + current = current.getSubAbility(); + } + return false; + } + + private boolean hasReactiveApi(SpellAbility sa) { + SpellAbility current = sa; + while (current != null) { + ApiType api = current.getApi(); + if (api == ApiType.Destroy + || api == ApiType.ChangeZone + || api == ApiType.DealDamage + || api == ApiType.Sacrifice + || api == ApiType.Draw + || api == ApiType.Discard + || api == ApiType.Mill + || api == ApiType.Token + || api == ApiType.Fight + || api == ApiType.GainControl + || api == ApiType.ExchangeControl + || api == ApiType.Play + || api == ApiType.PlayLandVariant) { + return true; + } + current = current.getSubAbility(); + } + return false; + } + + private boolean isUnsafeResult(SpellAbility sa, GameSimulator simulator, Score origScore, Score resultScore) { + if (resultScore.value == Integer.MIN_VALUE) { + return true; + } + + final int scoreLoss = origScore.value - resultScore.value; + if (scoreLoss >= CATASTROPHIC_SCORE_LOSS) { + return true; + } + + Player simPlayer = simulator.getSimulatedGameState().getPlayer(player.getId()); + if (simPlayer == null || !simPlayer.isInGame()) { + return true; + } + if (hasLatentOpponentDrawDanger(player.getGame())) { + if (hasSelfSpellCastDrawEngine(simPlayer) + && (sa.isSpell() || addsSelfSpellCastDrawEngine(simPlayer, sa))) { + return true; + } + if (addsSelfTurnDrawEngine(simPlayer, sa)) { + return true; + } + } + + if (resultScore.value >= origScore.value) { + return false; + } + + final int lifeLost = player.getLife() - simPlayer.getLife(); + final int significantLifeLoss = Math.max(4, player.getLife() / 4); + if (lifeLost >= significantLifeLoss) { + return true; + } + if (lifeLost > 0 && simPlayer.getLife() <= DANGEROUS_LIFE_TOTAL) { + return true; + } + + return scoreLoss >= BAD_SCORE_LOSS; + } + + private boolean drawsMultipleCardsForPlayer(SpellAbility sa) { + SpellAbility current = sa; + while (current != null) { + if (current.getApi() == ApiType.Draw && affectsPlayer(current)) { + if (getDrawAmount(current) >= 2) { + return true; + } + } + current = current.getSubAbility(); + } + return false; + } + + private boolean affectsPlayer(SpellAbility drawSa) { + final String defined = drawSa.getParamOrDefault("Defined", "You"); + if ("You".equals(defined) || "Player".equals(defined)) { + return true; + } + if (defined.startsWith("Targeted")) { + if (drawSa.getTargets() == null) { + return false; + } + for (Player target : drawSa.getTargets().getTargetPlayers()) { + if (target.equals(player)) { + return true; + } + } + } + return false; + } + + private int getDrawAmount(SpellAbility drawSa) { + if (!drawSa.hasParam("NumCards")) { + return 1; + } + try { + return AbilityUtils.calculateAmount(drawSa.getHostCard(), drawSa.getParam("NumCards"), drawSa); + } catch (RuntimeException ex) { + return 2; + } + } + + private boolean hasLatentOpponentDrawDanger(Game game) { + if (hasActiveOpponentDrawDanger(game)) { + return true; + } + for (Card card : game.getCardsIn(ZoneType.Command)) { + if (card.getController().isOpponentOf(player) && hasDangerousOpponentDrawTrigger(card)) { + return true; + } + } + return false; + } + + private boolean hasActiveOpponentDrawDanger(Game game) { + for (Card card : game.getCardsIn(ZoneType.Battlefield)) { + if (card.getController().isOpponentOf(player) && hasDangerousOpponentDrawTrigger(card)) { + return true; + } + } + return false; + } + + private boolean hasDangerousOpponentDrawTrigger(Card card) { + for (Trigger trigger : card.getTriggers()) { + if (trigger.getMode() != TriggerType.Drawn) { + continue; + } + SpellAbility triggerSa = trigger.ensureAbility(); + if (triggerSa == null) { + continue; + } + ApiType api = triggerSa.getApi(); + if (api != ApiType.DealDamage && api != ApiType.LoseLife && api != ApiType.Token) { + continue; + } + if (trigger.hasParam("ValidPlayer") && !trigger.matchesValidParam("ValidPlayer", player)) { + continue; + } + if (trigger.hasParam("ValidCard") + && !player.getCardsIn(ZoneType.Library).isEmpty() + && !trigger.matchesValidParam("ValidCard", player.getCardsIn(ZoneType.Library).get(0))) { + continue; + } + return true; + } + return false; + } + + private boolean addsSelfSpellCastDrawEngine(Player simPlayer, SpellAbility origSa) { + Card simHost = findByName(simPlayer.getGame(), origSa.getHostCard().getName()); + return simHost != null && simHost.getController().equals(simPlayer) && hasSelfSpellCastDrawTrigger(simHost); + } + + private boolean addsSelfTurnDrawEngine(Player simPlayer, SpellAbility origSa) { + Card simHost = findByName(simPlayer.getGame(), origSa.getHostCard().getName()); + return simHost != null && simHost.getController().equals(simPlayer) && hasSelfTurnDrawTrigger(simHost); + } + + private Card findByName(Game game, String name) { + for (Card card : game.getCardsIn(ZoneType.Battlefield)) { + if (card.getName().equals(name)) { + return card; + } + } + return null; + } + + private boolean hasSelfSpellCastDrawEngine(Player playerToCheck) { + for (Card card : playerToCheck.getCardsIn(ZoneType.Battlefield)) { + if (hasSelfSpellCastDrawTrigger(card)) { + return true; + } + } + return false; + } + + private boolean hasSelfSpellCastDrawTrigger(Card card) { + for (Trigger trigger : card.getTriggers()) { + if (trigger.getMode() != TriggerType.SpellCast) { + continue; + } + if (!trigger.matchesValidParam("ValidActivatingPlayer", card.getController())) { + continue; + } + SpellAbility triggerSa = trigger.ensureAbility(); + while (triggerSa != null) { + if (triggerSa.getApi() == ApiType.Draw) { + return true; + } + triggerSa = triggerSa.getSubAbility(); + } + } + return false; + } + + private boolean hasSelfTurnDrawTrigger(Card card) { + for (Trigger trigger : card.getTriggers()) { + if (trigger.getMode() != TriggerType.Phase) { + continue; + } + if (!"Draw".equalsIgnoreCase(trigger.getParamOrDefault("Phase", ""))) { + continue; + } + if (!drawsForSelfFromTrigger(trigger.ensureAbility())) { + continue; + } + return true; + } + return false; + } + + private boolean drawsForSelfFromTrigger(SpellAbility triggerSa) { + while (triggerSa != null) { + if (triggerSa.getApi() == ApiType.Draw) { + String defined = triggerSa.getParamOrDefault("Defined", "You"); + if ("You".equals(defined) || "TriggeredPlayer".equals(defined) || "Player".equals(defined)) { + return true; + } + } + triggerSa = triggerSa.getSubAbility(); + } + return false; + } + + private String makeCacheKey(SpellAbility sa) { + StringBuilder key = new StringBuilder(); + Game game = player.getGame(); + PhaseType phase = game.getPhaseHandler().getPhase(); + key.append("game=").append(game.getId()); + key.append("|phase=").append(phase); + key.append("|turn=").append(game.getPhaseHandler().getPlayerTurn().getId()); + key.append("|life=").append(player.getLife()); + key.append("|poison=").append(player.getPoisonCounters()); + key.append("|library=").append(player.getCardsIn(ZoneType.Library).size()); + key.append("|hand=").append(player.getCardsIn(ZoneType.Hand).size()); + key.append("|battlefield=").append(makeBattlefieldRulesFingerprint(game)); + key.append("|command=").append(makeCommandRulesFingerprint(game)); + key.append("|action=").append(sa.getHostCard().getName()); + key.append("|ability=").append(sa.getDescription()); + key.append("|x=").append(sa.getRootAbility().getXManaCostPaid()); + key.append("|targets=").append(makeTargetsFingerprint(sa)); + return key.toString(); + } + + private String makeBattlefieldRulesFingerprint(Game game) { + StringBuilder key = new StringBuilder(); + for (Card card : game.getCardsIn(ZoneType.Battlefield)) { + if (card.getTriggers().isEmpty() && card.getReplacementEffects().isEmpty() && card.getStaticAbilities().isEmpty()) { + continue; + } + key.append(card.getController().getId()).append(':') + .append(card.getName()).append(':') + .append(card.getGameTimestamp()).append(';'); + } + return key.toString(); + } + + private String makeCommandRulesFingerprint(Game game) { + StringBuilder key = new StringBuilder(); + for (Card card : game.getCardsIn(ZoneType.Command)) { + if (card.getTriggers().isEmpty() && card.getReplacementEffects().isEmpty() && card.getStaticAbilities().isEmpty()) { + continue; + } + key.append(card.getController().getId()).append(':') + .append(card.getName()).append(':') + .append(card.getGameTimestamp()).append(';'); + } + return key.toString(); + } + + private String makeTargetsFingerprint(SpellAbility sa) { + StringBuilder key = new StringBuilder(); + for (TargetChoices choices : sa.getAllTargetChoices()) { + for (GameObject target : choices) { + if (target instanceof IIdentifiable identifiable) { + key.append(identifiable.getId()).append(':'); + } + key.append(target).append(';'); + } + } + return key.toString(); + } +} diff --git a/forge-ai/src/main/java/forge/ai/simulation/SafetyThreatMemory.java b/forge-ai/src/main/java/forge/ai/simulation/SafetyThreatMemory.java new file mode 100644 index 00000000000..4a0fa8275db --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/simulation/SafetyThreatMemory.java @@ -0,0 +1,108 @@ +package forge.ai.simulation; + +import forge.game.ability.ApiType; +import forge.game.card.Card; +import forge.game.player.Player; +import forge.game.replacement.ReplacementEffect; +import forge.game.spellability.SpellAbility; +import forge.game.staticability.StaticAbility; +import forge.game.trigger.Trigger; +import forge.game.zone.ZoneType; + +import java.util.HashMap; +import java.util.Map; + +public class SafetyThreatMemory { + private static final int SAFETY_THREAT_BONUS = 150; + + private final Player player; + private final Map facts = new HashMap<>(); + + private static class SafetyThreatFact { + final String name; + final String unsafeAction; + final long gameTimestamp; + final int gameId; + + SafetyThreatFact(Card card, SpellAbility unsafeAction) { + this.name = card.getName(); + this.unsafeAction = unsafeAction.getHostCard().getName(); + this.gameTimestamp = card.getGameTimestamp(); + this.gameId = card.getGame().getId(); + } + + boolean matches(Card card) { + return gameId == card.getGame().getId() + && gameTimestamp == card.getGameTimestamp() + && name.equals(card.getName()) + && card.isInPlay(); + } + } + + public SafetyThreatMemory(Player player) { + this.player = player; + } + + public void rememberSuspectsForUnsafeAction(SpellAbility unsafeAction) { + for (Card card : player.getGame().getCardsIn(ZoneType.Battlefield)) { + if (!card.getController().isOpponentOf(player)) { + continue; + } + if (isPotentialSafetyThreat(card)) { + facts.put(card.getId(), new SafetyThreatFact(card, unsafeAction)); + } + } + } + + public int getThreatAssessmentBonus(Card card) { + SafetyThreatFact fact = facts.get(card.getId()); + if (fact == null) { + return 0; + } + if (!fact.matches(card)) { + facts.remove(card.getId()); + return 0; + } + return SAFETY_THREAT_BONUS; + } + + public static boolean isPotentialSafetyThreat(Card card) { + return hasRelevantTrigger(card) + || hasRelevantReplacementEffect(card) + || hasRelevantStaticAbility(card); + } + + private static boolean hasRelevantTrigger(Card card) { + for (Trigger trigger : card.getTriggers()) { + if (!trigger.isIntrinsic()) { + continue; + } + SpellAbility ability = trigger.ensureAbility(); + if (ability == null || ability.getApi() != ApiType.Mana) { + return true; + } + } + return false; + } + + private static boolean hasRelevantReplacementEffect(Card card) { + for (ReplacementEffect replacementEffect : card.getReplacementEffects()) { + if (replacementEffect.isIntrinsic()) { + return true; + } + } + return false; + } + + private static boolean hasRelevantStaticAbility(Card card) { + if (card.isCreature()) { + return false; + } + for (StaticAbility staticAbility : card.getStaticAbilities()) { + if (staticAbility.isIntrinsic()) { + return true; + } + } + return false; + } +} 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 23d7a118894..2e7ad94a73d 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java +++ b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java @@ -30,10 +30,12 @@ public class SpellAbilityPicker { private Plan plan; private int numSimulations; + private final OnePlaySafetyChecker safetyChecker; public SpellAbilityPicker(Game game, Player player) { this.game = game; this.player = player; + this.safetyChecker = new OnePlaySafetyChecker(player); } public void setInterceptor(SpellAbilityChoicesIterator in) { @@ -334,6 +336,12 @@ private AiPlayDecision canPlayAndPayForSim(final SpellAbility sa) { if (shouldWaitForLater(sa)) { return AiPlayDecision.AnotherTime; } + if (!OnePlaySafetyChecker.isChecking()) { + AiPlayDecision safety = safetyChecker.checkStatic(sa); + if (safety != AiPlayDecision.WillPlay) { + return safety; + } + } return AiPlayDecision.WillPlay; } diff --git a/forge-ai/src/main/java/forge/ai/simulation/SwingyPlaySimulationEvaluator.java b/forge-ai/src/main/java/forge/ai/simulation/SwingyPlaySimulationEvaluator.java new file mode 100644 index 00000000000..0138a96ecea --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/simulation/SwingyPlaySimulationEvaluator.java @@ -0,0 +1,153 @@ +package forge.ai.simulation; + +import forge.ai.AiAbilityDecision; +import forge.ai.AiPlayDecision; +import forge.ai.simulation.GameStateEvaluator.Score; +import forge.game.Game; +import forge.game.GameObject; +import forge.game.IIdentifiable; +import forge.game.phase.PhaseType; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.spellability.TargetChoices; +import forge.game.zone.ZoneType; +import forge.util.IHasForgeLog; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class SwingyPlaySimulationEvaluator implements IHasForgeLog { + private static final int CLEAR_SCORE_GAIN = 50; + private static final int CLEAR_SCORE_LOSS = 50; + private static final int MAX_CACHE_ENTRIES = 512; + private static final ThreadLocal EVALUATING = ThreadLocal.withInitial(() -> false); + + private static final Map CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + + private SwingyPlaySimulationEvaluator() { + } + + public static AiAbilityDecision judge(Player ai, SpellAbility sa) { + if (OnePlaySafetyChecker.isChecking() || EVALUATING.get() || needsTargetsButHasNone(sa)) { + return null; + } + + String key = makeCacheKey(ai, sa); + synchronized (CACHE) { + AiAbilityDecision cached = CACHE.get(key); + if (cached != null) { + return cached; + } + } + + AiAbilityDecision result = simulate(ai, sa); + if (result != null) { + synchronized (CACHE) { + CACHE.put(key, result); + } + } + return result; + } + + private static AiAbilityDecision simulate(Player ai, SpellAbility sa) { + EVALUATING.set(true); + try { + SimulationController controller = new SimulationController(new Score(0)) { + @Override + public boolean shouldRecurse() { + return false; + } + }; + GameSimulator simulator = new GameSimulator(controller, ai.getGame(), ai, null); + Score origScore = simulator.getScoreForOrigGame(); + Score resultScore = simulator.simulateSpellAbility(sa); + if (resultScore.value == Integer.MIN_VALUE) { + return new AiAbilityDecision(0, AiPlayDecision.CurseEffects); + } + if (resultScore.value == Integer.MAX_VALUE) { + return new AiAbilityDecision(100, AiPlayDecision.WillPlay); + } + + int scoreChange = resultScore.value - origScore.value; + if (scoreChange >= CLEAR_SCORE_GAIN) { + return new AiAbilityDecision(100, AiPlayDecision.WillPlay); + } + if (scoreChange <= -CLEAR_SCORE_LOSS) { + return new AiAbilityDecision(0, AiPlayDecision.CurseEffects); + } + return null; + } catch (RuntimeException ex) { + logSimulationFailure(ai, sa, ex); + return null; + } finally { + EVALUATING.set(false); + } + } + + private static boolean needsTargetsButHasNone(SpellAbility sa) { + SpellAbility current = sa; + while (current != null) { + if (current.usesTargeting()) { + TargetChoices targets = current.getTargets(); + if (targets == null || targets.isEmpty()) { + return true; + } + } + current = current.getSubAbility(); + } + return false; + } + + private static void logSimulationFailure(Player ai, SpellAbility sa, RuntimeException ex) { + Game game = ai.getGame(); + aiLog.warn(ex, "SwingyPlaySimulationEvaluator simulation failure: player={}, phase={}, turn={}, host={}, api={}, ability={}, targets={}", + ai, + game.getPhaseHandler().getPhase(), + game.getPhaseHandler().getPlayerTurn(), + sa.getHostCard(), + sa.getApi(), + sa, + makeTargetsFingerprint(sa)); + } + + private static String makeCacheKey(Player ai, SpellAbility sa) { + Game game = ai.getGame(); + StringBuilder key = new StringBuilder(); + PhaseType phase = game.getPhaseHandler().getPhase(); + key.append("game=").append(game.getId()); + key.append("|phase=").append(phase); + key.append("|turn=").append(game.getPhaseHandler().getPlayerTurn().getId()); + key.append("|player=").append(ai.getId()); + key.append("|life=").append(ai.getLife()); + key.append("|poison=").append(ai.getPoisonCounters()); + key.append("|hand=").append(ai.getCardsIn(ZoneType.Hand).size()); + key.append("|library=").append(ai.getCardsIn(ZoneType.Library).size()); + key.append("|battlefield="); + game.getCardsIn(ZoneType.Battlefield).forEach(card -> key.append(card.getController().getId()).append(':') + .append(card.getName()).append(':') + .append(card.getGameTimestamp()).append(';')); + key.append("|action=").append(sa.getHostCard().getName()); + key.append("|ability=").append(sa.getDescription()); + key.append("|x=").append(sa.getRootAbility().getXManaCostPaid()); + key.append("|targets=").append(makeTargetsFingerprint(sa)); + return key.toString(); + } + + private static String makeTargetsFingerprint(SpellAbility sa) { + StringBuilder key = new StringBuilder(); + for (TargetChoices choices : sa.getAllTargetChoices()) { + for (GameObject target : choices) { + if (target instanceof IIdentifiable identifiable) { + key.append(identifiable.getId()).append(':'); + } + key.append(target).append(';'); + } + } + return key.toString(); + } +} diff --git a/forge-gui-desktop/src/test/java/forge/ai/sacrifice/AiSacrificeDecisionTest.java b/forge-gui-desktop/src/test/java/forge/ai/sacrifice/AiSacrificeDecisionTest.java index 7d0adf95a8a..76e4e15730b 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/sacrifice/AiSacrificeDecisionTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/sacrifice/AiSacrificeDecisionTest.java @@ -1,9 +1,13 @@ package forge.ai.sacrifice; import forge.ai.AITest; +import forge.ai.PlayerControllerAi; import forge.game.Game; import forge.game.card.Card; +import forge.game.card.CardCollection; +import forge.game.card.CardCollectionView; import forge.game.player.Player; +import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; import org.testng.AssertJUnit; import org.testng.annotations.Test; @@ -13,6 +17,90 @@ */ public class AiSacrificeDecisionTest extends AITest { + @Test + public void testSimulationSacrificeChoiceIsDeterministic() { + Game game = initAndCreateGame(); + + Player ai = game.getPlayers().get(1); + SpellAbility source = sacrificeSource(ai); + + Card bear = addCard("Runeclaw Bear", ai); + addCard("Serra Angel", ai); + addCard("Colossal Dreadmaw", ai); + + CardCollection validTargets = creatures(ai); + + for (int i = 0; i < 5; i++) { + CardCollectionView chosen = chooseWithSimulationController(game, ai, source, 1, validTargets); + + AssertJUnit.assertEquals("Simulation sacrifice choice should be stable", 1, chosen.size()); + AssertJUnit.assertEquals("AI should consistently choose the least valuable creature", + bear, chosen.get(0)); + } + } + + @Test + public void testSimulationSacrificeChoiceUsesOnlyLegalCandidates() { + Game game = initAndCreateGame(); + + Player ai = game.getPlayers().get(1); + SpellAbility source = sacrificeSource(ai); + + addCard("Runeclaw Bear", ai); + Card angel = addCard("Serra Angel", ai); + Card dreadmaw = addCard("Colossal Dreadmaw", ai); + + CardCollection validTargets = new CardCollection(); + validTargets.add(angel); + validTargets.add(dreadmaw); + + CardCollectionView chosen = chooseWithSimulationController(game, ai, source, 1, validTargets); + + AssertJUnit.assertEquals("AI should choose one legal creature to sacrifice", 1, chosen.size()); + AssertJUnit.assertEquals("AI should choose the least valuable legal creature", + angel, chosen.get(0)); + } + + @Test + public void testSimulationSacrificeChoiceChoosesWorstCreaturesFirst() { + Game game = initAndCreateGame(); + + Player ai = game.getPlayers().get(1); + SpellAbility source = sacrificeSource(ai); + + Card bear = addCard("Runeclaw Bear", ai); + Card angel = addCard("Serra Angel", ai); + Card dreadmaw = addCard("Colossal Dreadmaw", ai); + + CardCollectionView chosen = chooseWithSimulationController(game, ai, source, 2, creatures(ai)); + + AssertJUnit.assertEquals("AI should choose the requested number of creatures", 2, chosen.size()); + AssertJUnit.assertTrue("AI should sacrifice Runeclaw Bear before better creatures", chosen.contains(bear)); + AssertJUnit.assertTrue("AI should sacrifice Serra Angel before Colossal Dreadmaw", chosen.contains(angel)); + AssertJUnit.assertFalse("AI should preserve the most valuable creature when sacrificing two of three", + chosen.contains(dreadmaw)); + } + + @Test + public void testSimulationSacrificeChoiceCanChooseLeastValuablePermanent() { + Game game = initAndCreateGame(); + + Player ai = game.getPlayers().get(1); + SpellAbility source = sacrificeSource(ai); + + Card ornithopter = addCard("Ornithopter", ai); + addCard("Sol Ring", ai); + addCard("Serra Angel", ai); + + CardCollection validTargets = new CardCollection(ai.getCardsIn(ZoneType.Battlefield)); + + CardCollectionView chosen = chooseWithSimulationController(game, ai, source, 1, validTargets); + + AssertJUnit.assertEquals("AI should choose one permanent to sacrifice", 1, chosen.size()); + AssertJUnit.assertEquals("AI should choose the cheapest artifact/enchantment in a mixed permanent set", + ornithopter, chosen.get(0)); + } + @Test public void testAiSacrificesCreatureToAvoidLethalLifeLoss() { Game game = initAndCreateGame(); @@ -33,11 +121,7 @@ public void testAiSacrificesCreatureToAvoidLethalLifeLoss() { // Opponent controls a creature they can sacrifice Card adelbert = addCard("Adelbert Steiner", opponent); - // Fill libraries to prevent draw-death - for (int i = 0; i < 10; i++) { - addCardToZone("Plains", p, ZoneType.Library); - addCardToZone("Plains", opponent, ZoneType.Library); - } + fillLibraries(p, opponent, 10); // Play until next turn (after Fandaniel's end step trigger resolves) this.playUntilNextTurn(game); @@ -45,9 +129,8 @@ public void testAiSacrificesCreatureToAvoidLethalLifeLoss() { // The game should NOT be over - opponent should have sacrificed to survive AssertJUnit.assertFalse("Game should not be over - AI should have sacrificed to survive", game.isGameOver()); - // Adelbert should be in graveyard (sacrificed) - AssertJUnit.assertTrue("Adelbert Steiner should be in graveyard after being sacrificed", - opponent.getZone(ZoneType.Graveyard).contains(adelbert)); + assertInZone(opponent, adelbert, ZoneType.Graveyard, + "Adelbert Steiner should be in graveyard after being sacrificed"); // Opponent should still have 1 life (didn't lose life because they sacrificed) AssertJUnit.assertEquals("Opponent should still have 1 life after sacrificing", 1, opponent.getLife()); @@ -74,11 +157,7 @@ public void testAiDoesNotSacrificeWhenLifeLossIsNotLethal() { // Opponent controls a creature Card adelbert = addCard("Adelbert Steiner", opponent); - // Fill libraries - for (int i = 0; i < 10; i++) { - addCardToZone("Plains", p, ZoneType.Library); - addCardToZone("Plains", opponent, ZoneType.Library); - } + fillLibraries(p, opponent, 10); // Play until next turn (after Fandaniel's end step trigger resolves) this.playUntilNextTurn(game); @@ -86,9 +165,8 @@ public void testAiDoesNotSacrificeWhenLifeLossIsNotLethal() { // Game should not be over AssertJUnit.assertFalse("Game should not be over", game.isGameOver()); - // Adelbert should still be on battlefield (not sacrificed) - AssertJUnit.assertTrue("Adelbert Steiner should still be on battlefield when life loss isn't lethal", - opponent.getZone(ZoneType.Battlefield).contains(adelbert)); + assertInZone(opponent, adelbert, ZoneType.Battlefield, + "Adelbert Steiner should still be on battlefield when life loss isn't lethal"); // Opponent should have 8 life (lost 2 from not sacrificing) AssertJUnit.assertEquals("Opponent should have lost 2 life from not sacrificing", 8, opponent.getLife()); @@ -112,11 +190,7 @@ public void testAiSacrificesToPillarTombsToAvoidLethalLifeLoss() { // Opponent controls a creature they can sacrifice Card bear = addCard("Runeclaw Bear", opponent); - // Fill libraries to prevent draw-death - for (int i = 0; i < 10; i++) { - addCardToZone("Plains", p, ZoneType.Library); - addCardToZone("Plains", opponent, ZoneType.Library); - } + fillLibraries(p, opponent, 10); // Play two turns - first ends Player 1's turn, second plays through opponent's upkeep // where Pillar Tombs triggers and the AI must decide whether to sacrifice @@ -126,11 +200,48 @@ public void testAiSacrificesToPillarTombsToAvoidLethalLifeLoss() { // The game should NOT be over - AI should have sacrificed to survive AssertJUnit.assertFalse("Game should not be over - AI should have sacrificed to survive", game.isGameOver()); - // Bear should be in graveyard (sacrificed) - AssertJUnit.assertTrue("Runeclaw Bear should be in graveyard after being sacrificed", - opponent.getZone(ZoneType.Graveyard).contains(bear)); + assertInZone(opponent, bear, ZoneType.Graveyard, + "Runeclaw Bear should be in graveyard after being sacrificed"); // Opponent should still have 4 life (didn't lose life because they sacrificed) AssertJUnit.assertEquals("Opponent should still have 4 life after sacrificing", 4, opponent.getLife()); } + + private SpellAbility sacrificeSource(Player ai) { + SpellAbility source = createCard("Innocent Blood", ai).getFirstSpellAbility(); + source.setActivatingPlayer(ai); + return source; + } + + private CardCollection creatures(Player player) { + CardCollection creatures = new CardCollection(); + for (Card card : player.getCardsIn(ZoneType.Battlefield)) { + if (card.isCreature()) { + creatures.add(card); + } + } + return creatures; + } + + private void fillLibraries(Player first, Player second, int count) { + for (int i = 0; i < count; i++) { + addCardToZone("Plains", first, ZoneType.Library); + addCardToZone("Plains", second, ZoneType.Library); + } + } + + private void assertInZone(Player player, Card card, ZoneType zone, String message) { + AssertJUnit.assertTrue(message, player.getZone(zone).contains(card)); + } + + private CardCollectionView chooseWithSimulationController(Game game, Player ai, SpellAbility source, + int amount, CardCollectionView validTargets) { + PlayerControllerAi controller = new PlayerControllerAi(game, ai, ai.getLobbyPlayer()); + controller.setUseSimulation(true); + + final CardCollectionView[] chosen = new CardCollectionView[1]; + ai.runWithController(() -> chosen[0] = ai.getController().choosePermanentsToSacrifice( + source, amount, amount, validTargets, "Choose permanents to sacrifice"), controller); + return chosen[0]; + } } diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java index 81676f97669..cff3dd9239e 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/SpellAbilityPickerSimulationTest.java @@ -9,12 +9,19 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import forge.ai.AiPlayDecision; +import forge.ai.AiController; +import forge.ai.ComputerUtilCard; +import forge.ai.PlayerControllerAi; +import forge.ai.simulation.GameStateEvaluator.Score; import forge.game.Game; import forge.game.card.Card; +import forge.game.card.CardCollection; import forge.game.card.CounterEnumType; import forge.game.combat.Combat; import forge.game.phase.PhaseType; import forge.game.player.Player; +import forge.game.spellability.Spell; import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; @@ -66,6 +73,876 @@ public void testPickingKillingCreature() { AssertJUnit.assertNull(sa.getTargets().getFirstTargetedPlayer()); } + @Test + public void testOnePlaySafetyLearnsLethalDrawTriggerDamage() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + p.setLife(2, null); + + addCards("Island", 3, p); + Card divination = addCardToZone("Divination", p, ZoneType.Hand); + fillLibrary(p, 2); + + Player opponent = opponentPlayer(game); + Card xyris = addCard("Xyris, the Writhing Storm", opponent); + Card impactTremors = addCard("Impact Tremors", opponent); + + moveToMainPhase(game, p); + + AiController ai = ai(p); + SpellAbility drawSa = firstAbility(divination, p); + AssertJUnit.assertEquals(AiPlayDecision.CurseEffects, ai.checkOnePlaySafety(drawSa)); + AssertJUnit.assertNull(ai.chooseSpellAbilityToPlay()); + AssertJUnit.assertNull(ai.chooseSpellAbilityToPlay()); + AssertJUnit.assertTrue("Safety checker should remember Xyris as a removal threat", + ai.getSafetyThreatBonus(xyris) > 0); + AssertJUnit.assertTrue("Safety checker should remember Impact Tremors as a removal threat", + ai.getSafetyThreatBonus(impactTremors) > 0); + } + + @Test + public void testSafetyFactRaisesRemovalPriority() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + p.setLife(2, null); + + addCards("Island", 3, p); + Card divination = addCardToZone("Divination", p, ZoneType.Hand); + fillLibrary(p, 2); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + Card impactTremors = addCard("Impact Tremors", opponent); + Card serraAngel = addCard("Serra Angel", opponent); + + moveToMainPhase(game, p); + + AiController ai = ai(p); + SpellAbility drawSa = firstAbility(divination, p); + AssertJUnit.assertEquals(AiPlayDecision.CurseEffects, ai.checkOnePlaySafety(drawSa)); + Card beastWithin = addCardToZone("Beast Within", p, ZoneType.Hand); + + CardCollection possibleTargets = new CardCollection(); + possibleTargets.add(serraAngel); + possibleTargets.add(impactTremors); + AssertJUnit.assertEquals("Learned safety facts should make Impact Tremors the preferred removal target", + impactTremors, ComputerUtilCard.getKnownSafetyThreatToRemove(p, possibleTargets)); + + SpellAbility removalSa = firstAbility(beastWithin, p); + AssertJUnit.assertTrue("Learned safety facts should make the AI willing to spend removal now", + ComputerUtilCard.useRemovalNow(removalSa, impactTremors, 0, ZoneType.Graveyard)); + } + + @Test + public void testSafetyFactLearnsGravePactAsRemovalPriority() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Swamp", 3, p); + addCard("Blightsteel Colossus", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = opponentPlayer(game); + Card gravePact = addCard("Grave Pact", opponent); + Card angel = addCard("Serra Angel", opponent); + + moveToMainPhase(game, p); + + AiController ai = ai(p); + SpellAbility murderSa = firstAbility(murder, p); + murderSa.getTargets().add(angel); + + AssertJUnit.assertEquals(AiPlayDecision.CurseEffects, ai.checkOnePlaySafety(murderSa)); + AssertJUnit.assertTrue("Safety checker should remember Grave Pact as a removal threat", + ai.getSafetyThreatBonus(gravePact) > 0); + AssertJUnit.assertEquals("A plain creature should not be blamed for the unsafe result", + 0, ai.getSafetyThreatBonus(angel)); + + CardCollection possibleTargets = new CardCollection(); + possibleTargets.add(angel); + possibleTargets.add(gravePact); + AssertJUnit.assertEquals("Learned safety facts should make Grave Pact the preferred removal target", + gravePact, ComputerUtilCard.getKnownSafetyThreatToRemove(p, possibleTargets)); + } + + @Test + public void testOnePlaySafetyAvoidsWheelIntoXyris() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Mountain", 3, p); + addCardToZone("Wheel of Fortune", p, ZoneType.Hand); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @DataProvider(name = "optionalWheelIntoXyrisData") + public static Object[][] optionalWheelIntoXyrisData() { + return new Object[][] { + {"Wheel of Fate"}, + {"Wheel of Fortune"} + }; + } + + @Test(dataProvider = "optionalWheelIntoXyrisData") + public void testOptionalEffectCastAvoidsWheelIntoXyris(String wheelName) { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + Card wheel = addCardToZone(wheelName, p, ZoneType.Exile); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + AssertJUnit.assertEquals(AiPlayDecision.CurseEffects, optionalFreeCastDecision(p, wheel)); + } + + @Test + public void testOptionalEffectCastAllowsWheelOfFortuneIntoCommandZoneXyris() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + Card wheel = addCardToZone("Wheel of Fortune", p, ZoneType.Exile); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCardToZone("Xyris, the Writhing Storm", opponent, ZoneType.Command); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + Spell freeCast = makeOptionalFreeCast(p, wheel); + AiPlayDecision decision = new OnePlaySafetyChecker(p).checkStatic(freeCast); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, decision); + } + + @Test + public void testOnePlaySafetyAvoidsArjunKederektPain() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + p.setLife(20, null); + + addCard("Arjun, the Shifting Flame", p); + addCard("Mountain", p); + addCardToZone("Shock", p, ZoneType.Hand); + for (int i = 0; i < 6; i++) { + addCardToZone("Runeclaw Bear", p, ZoneType.Hand); + addCardToZone("Runeclaw Bear", p, ZoneType.Library); + } + + Player opponent = opponentPlayer(game); + addCard("Kederekt Parasite", opponent); + addCard("Raging Goblin", opponent); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + private Spell makeOptionalFreeCast(Player player, Card card) { + Spell freeCast = (Spell) card.getFirstSpellAbility().copyWithNoManaCost(player); + freeCast.setActivatingPlayer(player); + freeCast.setCastFromPlayEffect(true); + return freeCast; + } + + private AiPlayDecision optionalFreeCastDecision(Player player, Card card) { + return ai(player).canPlayFromEffectAI(makeOptionalFreeCast(player, card), false, true); + } + + private Player setupAi(Game game, boolean useSimulation) { + Player player = aiPlayer(game); + player.setTeam(0); + opponentPlayer(game).setTeam(1); + ai(player).setUseSimulation(useSimulation); + return player; + } + + private Player aiPlayer(Game game) { + return game.getPlayers().get(1); + } + + private Player opponentPlayer(Game game) { + return game.getPlayers().get(0); + } + + private AiController ai(Player player) { + return ((PlayerControllerAi) player.getController()).getAi(); + } + + private SpellAbility firstAbility(Card card, Player player) { + SpellAbility sa = card.getFirstSpellAbility(); + sa.setActivatingPlayer(player); + return sa; + } + + private void fillLibrary(Player player, int count) { + for (int i = 0; i < count; i++) { + addCardToZone("Runeclaw Bear", player, ZoneType.Library); + } + } + + private void moveToMainPhase(Game game, Player player) { + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, player); + game.getAction().checkStateEffects(true); + } + + private void assertNoPlayableSpell(Player player) { + AssertJUnit.assertNull(ai(player).chooseSpellAbilityToPlay()); + } + + @Test + public void testOnePlaySafetyAvoidsMindmoilWithDrawPunisherInPlayOrCommand() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Mountain", 5, p); + addCardToZone("Mindmoil", p, ZoneType.Hand); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Kederekt Parasite", opponent); + addCard("Raging Goblin", opponent); + addCardToZone("Nekusar, the Mindrazer", opponent, ZoneType.Command); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testOnePlaySafetyAvoidsPuzzleBoxIntoXyris() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Island", 4, p); + addCardToZone("Teferi's Puzzle Box", p, ZoneType.Hand); + fillLibrary(p, 7); + + addCard("Xyris, the Writhing Storm", opponentPlayer(game)); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testOnePlaySafetyAvoidsPuzzleBoxIntoNekusarInCommand() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Island", 4, p); + addCardToZone("Teferi's Puzzle Box", p, ZoneType.Hand); + fillLibrary(p, 7); + + addCardToZone("Nekusar, the Mindrazer", opponentPlayer(game), ZoneType.Command); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testOnePlaySafetyAvoidsPuzzleBoxIntoKederektParasiteWithRedPermanent() { + Game game = initAndCreateGame(); + Player p = setupAi(game, false); + + addCards("Island", 4, p); + addCardToZone("Teferi's Puzzle Box", p, ZoneType.Hand); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Kederekt Parasite", opponent); + addCard("Raging Goblin", opponent); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testSimulationAiAvoidsMindmoilWithDrawPunisherInPlayOrCommand() { + Game game = initAndCreateGame(); + Player p = setupAi(game, true); + + addCards("Mountain", 5, p); + addCardToZone("Mindmoil", p, ZoneType.Hand); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + addCardToZone("Nekusar, the Mindrazer", opponent, ZoneType.Command); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @DataProvider(name = "simulationModes") + public static Object[][] simulationModes() { + return new Object[][] { + {false}, + {true} + }; + } + + @Test(dataProvider = "simulationModes") + public void testAiAvoidsTimetwisterIntoDrawPunisherInPlayOrCommand(boolean useSimulation) { + Game game = initAndCreateGame(); + Player p = setupAi(game, useSimulation); + + addCards("Island", 3, p); + addCardToZone("Timetwister", p, ZoneType.Hand); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + addCardToZone("Nekusar, the Mindrazer", opponent, ZoneType.Command); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @DataProvider(name = "echoIntoXyrisImpactTremorsData") + public static Object[][] echoIntoXyrisImpactTremorsData() { + return new Object[][] { + {false, ZoneType.Hand, 6}, + {false, ZoneType.Graveyard, 3}, + {true, ZoneType.Hand, 6}, + {true, ZoneType.Graveyard, 3} + }; + } + + @Test(dataProvider = "echoIntoXyrisImpactTremorsData") + public void testAiAvoidsEchoOfEonsIntoXyrisImpactTremors(boolean useSimulation, ZoneType echoZone, int islandCount) { + Game game = initAndCreateGame(); + Player p = setupAi(game, useSimulation); + p.setLife(6, null); + + addCards("Island", islandCount, p); + addCardToZone("Echo of Eons", p, echoZone); + fillLibrary(p, 7); + + Player opponent = opponentPlayer(game); + addCard("Xyris, the Writhing Storm", opponent); + addCard("Impact Tremors", opponent); + fillLibrary(opponent, 7); + + moveToMainPhase(game, p); + + assertNoPlayableSpell(p); + } + + @Test + public void testOnePlaySafetyAvoidsBadRemovalIntoGravePact() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCards("Swamp", 3, p); + addCard("Blightsteel Colossus", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Grave Pact", opponent); + Card angel = addCard("Serra Angel", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = murder.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(angel); + + AssertJUnit.assertEquals(AiPlayDecision.CurseEffects, new OnePlaySafetyChecker(p).check(sa)); + } + + @Test + public void testOnePlaySafetyAllowsGoodRemovalIntoGravePact() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCards("Swamp", 3, p); + addCard("Runeclaw Bear", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Grave Pact", opponent); + Card dragon = addCard("Shivan Dragon", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = murder.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(dragon); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, new OnePlaySafetyChecker(p).check(sa)); + } + + @Test + public void testSimulationScorePenalizesBadRemovalIntoGravePact() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + + addCards("Swamp", 3, p); + addCard("Blightsteel Colossus", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Grave Pact", opponent); + Card angel = addCard("Serra Angel", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = murder.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(angel); + + GameSimulator simulator = createSimulator(game, p); + Score origScore = simulator.getScoreForOrigGame(); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertTrue(resultScore.value < origScore.value); + } + + @Test + public void testSimulationScoreRewardsGoodRemovalIntoGravePact() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + + addCards("Swamp", 3, p); + addCard("Runeclaw Bear", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Grave Pact", opponent); + Card dragon = addCard("Shivan Dragon", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = murder.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(dragon); + + GameSimulator simulator = createSimulator(game, p); + Score origScore = simulator.getScoreForOrigGame(); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertTrue(resultScore.value > origScore.value); + } + + @Test + public void testSimulationScorePenalizesBadRemovalIntoDictateOfErebos() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + + addCards("Swamp", 3, p); + addCard("Blightsteel Colossus", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Dictate of Erebos", opponent); + Card angel = addCard("Serra Angel", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = murder.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(angel); + + assertSimulationScoreDecreases(game, p, sa); + } + + @Test + public void testSimulationScorePenalizesBadRemovalIntoButcherOfMalakir() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + + addCards("Swamp", 3, p); + addCard("Blightsteel Colossus", p); + Card murder = addCardToZone("Murder", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Butcher of Malakir", opponent); + Card angel = addCard("Serra Angel", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = murder.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(angel); + + assertSimulationScoreDecreases(game, p, sa); + } + + @Test + public void testSimulationScoreRewardsBloodArtistLethalWrath() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + p.setLife(5, null); + + addCard("Blood Artist", p); + addCard("Runeclaw Bear", p); + addCard("Runeclaw Bear", p); + addCards("Plains", 4, p); + Card wrath = addCardToZone("Wrath of God", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + opponent.setLife(3, null); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = wrath.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + GameSimulator simulator = createSimulator(game, p); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertEquals(Integer.MAX_VALUE, resultScore.value); + } + + @Test + public void testSimulationScoreRewardsZulaportCutthroatLethalWrath() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + p.setLife(5, null); + + addCard("Zulaport Cutthroat", p); + addCard("Runeclaw Bear", p); + addCard("Runeclaw Bear", p); + addCards("Plains", 4, p); + Card wrath = addCardToZone("Wrath of God", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + opponent.setLife(3, null); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = wrath.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + GameSimulator simulator = createSimulator(game, p); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertEquals(Integer.MAX_VALUE, resultScore.value); + } + + @Test + public void testSimulationScorePenalizesRampantGrowthIntoZoZu() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + p.setLife(2, null); + + addCards("Forest", 2, p); + addCardToZone("Forest", p, ZoneType.Library); + Card growth = addCardToZone("Rampant Growth", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Zo-Zu the Punisher", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = growth.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + GameSimulator simulator = createSimulator(game, p); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertEquals(Integer.MIN_VALUE, resultScore.value); + } + + @Test + public void testSimulationScorePenalizesRampantGrowthIntoPollutedBonds() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + p.setLife(2, null); + + addCards("Forest", 2, p); + addCardToZone("Forest", p, ZoneType.Library); + Card growth = addCardToZone("Rampant Growth", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Polluted Bonds", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = growth.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + GameSimulator simulator = createSimulator(game, p); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertEquals(Integer.MIN_VALUE, resultScore.value); + } + + @Test + public void testOnePlaySafetyAvoidsTriggeringMindmoilIntoLethalDrawPunishment() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + p.setLife(10, null); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCard("Mindmoil", p); + addCard("Mountain", p); + addCardToZone("Shock", p, ZoneType.Hand); + for (int i = 0; i < 6; i++) { + addCardToZone("Runeclaw Bear", p, ZoneType.Hand); + addCardToZone("Runeclaw Bear", p, ZoneType.Library); + } + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Kederekt Parasite", opponent); + addCard("Nekusar, the Mindrazer", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertNull(((PlayerControllerAi) p.getController()).getAi().chooseSpellAbilityToPlay()); + } + + @Test + public void testOnePlaySafetyAvoidsMindmoilGivingXyrisTokens() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCards("Mountain", 5, p); + addCardToZone("Mindmoil", p, ZoneType.Hand); + for (int i = 0; i < 7; i++) { + addCardToZone("Runeclaw Bear", p, ZoneType.Library); + } + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Xyris, the Writhing Storm", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertNull(((PlayerControllerAi) p.getController()).getAi().chooseSpellAbilityToPlay()); + } + + @Test + public void testOnePlaySafetyAllowsCreatureDespiteDrawPunisher() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCards("Forest", 2, p); + Card bear = addCardToZone("Runeclaw Bear", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Xyris, the Writhing Storm", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = bear.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, new OnePlaySafetyChecker(p).check(sa)); + } + + @Test + public void testOnePlaySafetyAllowsJhoiraCommanderDespiteDrawPunisher() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + Card jhoira = addCommanderToCommandZone("Jhoira, Weatherlight Captain", p); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Xyris, the Writhing Storm", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = jhoira.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, new OnePlaySafetyChecker(p).check(sa)); + } + + @Test + public void testOnePlaySafetyAllowsKamiCommanderDespiteDrawPunisher() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + Card kami = addCommanderToCommandZone("Kami of the Crescent Moon", p); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Xyris, the Writhing Storm", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = kami.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, new OnePlaySafetyChecker(p).check(sa)); + } + + @Test + public void testOnePlaySafetyAllowsNekusarCommanderDespiteDrawPunisher() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + Card nekusar = addCommanderToCommandZone("Nekusar, the Mindrazer", p); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Xyris, the Writhing Storm", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = nekusar.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, new OnePlaySafetyChecker(p).check(sa)); + } + + @Test + public void testOnePlaySafetyAllowsRemovingDrawPunisher() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCards("Forest", 3, p); + Card beastWithin = addCardToZone("Beast Within", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + Card xyris = addCard("Xyris, the Writhing Storm", opponent); + addCardToZone("Nekusar, the Mindrazer", opponent, ZoneType.Command); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = beastWithin.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + sa.getTargets().add(xyris); + + AssertJUnit.assertEquals(AiPlayDecision.WillPlay, new OnePlaySafetyChecker(p).check(sa)); + } + + private Card addCommanderToCommandZone(String name, Player player) { + Card commander = addCardToZone(name, player, ZoneType.Command); + player.addCommander(commander); + return commander; + } + + private void assertSimulationScoreDecreases(Game game, Player player, SpellAbility sa) { + GameSimulator simulator = createSimulator(game, player); + Score origScore = simulator.getScoreForOrigGame(); + Score resultScore = simulator.simulateSpellAbility(sa); + + AssertJUnit.assertTrue(resultScore.value < origScore.value); + } + + @Test + public void testOnePlaySimulationAllowsWrathWithFavorableDeathTriggers() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCard("Teysa Karlov", p); + addCard("Xathrid Necromancer", p); + addCards("Plains", 4, p); + Card wrath = addCardToZone("Wrath of God", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + List chosen = ((PlayerControllerAi) p.getController()).getAi().chooseSpellAbilityToPlay(); + + AssertJUnit.assertNotNull(chosen); + AssertJUnit.assertEquals(1, chosen.size()); + AssertJUnit.assertEquals(wrath, chosen.get(0).getHostCard()); + } + + @Test + public void testOnePlaySimulationRejectsBadWrath() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + p.setTeam(0); + ((PlayerControllerAi) p.getController()).getAi().setUseSimulation(false); + + addCard("Shivan Dragon", p); + addCards("Plains", 4, p); + addCardToZone("Wrath of God", p, ZoneType.Hand); + + Player opponent = game.getPlayers().get(0); + opponent.setTeam(1); + addCard("Runeclaw Bear", opponent); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertNull(((PlayerControllerAi) p.getController()).getAi().chooseSpellAbilityToPlay()); + } + @Test public void testSequenceStartingWithPlayingLand() { Game game = initAndCreateGame();