diff --git a/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java b/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java index bee5860484c7..239f6304b4ab 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java +++ b/forge-ai/src/main/java/forge/ai/simulation/OnePlaySafetyChecker.java @@ -1,11 +1,20 @@ package forge.ai.simulation; import forge.ai.simulation.GameStateEvaluator.Score; +import forge.game.Game; +import forge.game.ability.AbilityUtils; +import forge.game.ability.ApiType; import forge.game.card.Card; import forge.game.player.Player; import forge.game.spellability.SpellAbility; +import forge.game.trigger.Trigger; +import forge.game.trigger.TriggerType; import forge.game.zone.ZoneType; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; + public final class OnePlaySafetyChecker { private static final ThreadLocal CHECKING = ThreadLocal.withInitial(() -> false); @@ -20,30 +29,88 @@ public static boolean isAcceptable(Player player, SpellAbility sa) { CHECKING.set(true); try { - SimulationController controller = new SimulationController(new Score(0), 0); - GameSimulator simulator = new GameSimulator(controller, player.getGame(), player, null); + GameSimulator simulator = new GameSimulator( + new SimulationController(new Score(0), 0), + player.getGame(), player, null); Score originalScore = simulator.getScoreForOrigGame(); - Score resultScore = simulator.simulateSpellAbility(sa); + Game simulatedGame = simulator.getSimulatedGameState(); + Set existingCommandCards = new HashSet<>(simulatedGame.getCardsIn(ZoneType.Command)); + Set existingScheduledTriggers = new HashSet<>( + simulatedGame.getTriggerHandler().getScheduledDelayedTriggers()); + // Validate choices already encoded on the ability; resolution and payment + // choices may still be selected independently in the simulated game. + Score scoreAfterCosts = simulator.simulateSpellAbility(sa, false); + Player simulatedPlayer = (Player) simulator.getGameCopier().find(player); - // desperate plays are ok if next combat was already likely to kill AI + if (simulatedPlayer == null || scoreAfterCosts.value == Integer.MIN_VALUE) { + return true; + } + GameSimulator.resolveStack(simulatedGame, simulatedPlayer.getWeakestOpponent()); + if (simulatedPlayer.hasLost()) { + return false; + } + resolveScheduledEffects(simulatedGame, simulatedPlayer, + existingCommandCards, existingScheduledTriggers); + if (simulatedPlayer.hasLost()) { + return false; + } + Score resultScore = new GameStateEvaluator().getScoreForGameState(simulatedGame, simulatedPlayer); + // A land is already on the battlefield in the post-cost snapshot, so its + // enters-the-battlefield drawback must be weighed against the pre-play state. + Score minimumScore = sa.isLandAbility() ? originalScore : scoreAfterCosts; return resultScore.value == Integer.MIN_VALUE - || resultScore.value >= (long) originalScore.value - expectedCardScoreLoss(player, sa, simulator); + || resultScore.value >= minimumScore.value; } finally { CHECKING.remove(); } } - // sometimes simulation might not see the effect of some heuristics directly, so at least negate any card disadvantage - private static int expectedCardScoreLoss(Player player, SpellAbility sa, GameSimulator simulator) { - Card source = sa.getHostCard(); - if (source == null || !source.isInZone(ZoneType.Hand)) { - return 0; + private static void resolveScheduledEffects(Game game, Player player, + Set existingCommandCards, Set existingScheduledTriggers) { + Set triggers = new LinkedHashSet<>( + game.getTriggerHandler().getScheduledDelayedTriggers()); + triggers.removeAll(existingScheduledTriggers); + game.getCardsIn(ZoneType.Command).stream() + .filter(effect -> !existingCommandCards.contains(effect)) + .flatMap(effect -> effect.getTriggers().stream()) + .filter(trigger -> trigger.getMode() == TriggerType.Phase && trigger.hasParam("OneOff")) + .forEach(triggers::add); + + boolean resolved = false; + for (Trigger trigger : triggers) { + SpellAbility ability = trigger.ensureAbility(); + if (!isMandatoryScheduledEffect(trigger, ability)) { + continue; + } + if (ability.getActivatingPlayer() == null) { + ability.setActivatingPlayer(trigger.getHostCard().getController()); + } + AbilityUtils.resolve(ability); + resolved = true; + } + if (resolved && !game.isGameOver()) { + GameSimulator.resolveStack(game, player.getWeakestOpponent()); + } + } + + private static boolean isMandatoryScheduledEffect(Trigger trigger, SpellAbility ability) { + if (trigger.getMode() != TriggerType.Phase || ability == null + || trigger.hasParam("OptionalDecider")) { + return false; } - Card simulatedSource = (Card) simulator.getGameCopier().find(source); - if (simulatedSource.isInZone(ZoneType.Hand)) { - return 0; + boolean supportedEffect = false; + for (SpellAbility current = ability; current != null; current = current.getSubAbility()) { + if (current.hasParam("UnlessCost") || current.hasParam("OptionalDecider") + || "True".equalsIgnoreCase(current.getParam("Optional"))) { + return false; + } + if (current.getApi() == ApiType.Draw) { + supportedEffect |= trigger.hasParam("NextTurn"); + } else if (current.getApi() == ApiType.ChangeZone || current.getApi() == ApiType.ChangeZoneAll) { + ZoneType destination = ZoneType.smartValueOf(current.getParam("Destination")); + supportedEffect |= destination == ZoneType.Hand || destination == ZoneType.Battlefield; + } } - // Match GameStateEvaluator: excess cards are worth one point, other hand cards five. - return !player.isUnlimitedHandSize() && player.getCardsIn(ZoneType.Hand).size() > player.getMaxHandSize() ? 1 : 5; + return supportedEffect; } } diff --git a/forge-game/src/main/java/forge/game/ability/effects/DelayedTriggerEffect.java b/forge-game/src/main/java/forge/game/ability/effects/DelayedTriggerEffect.java index f06f35aa69bf..0371dbb9370b 100644 --- a/forge-game/src/main/java/forge/game/ability/effects/DelayedTriggerEffect.java +++ b/forge-game/src/main/java/forge/game/ability/effects/DelayedTriggerEffect.java @@ -87,7 +87,7 @@ public void resolve(SpellAbility sa) { } else if (mapParams.containsKey("ThisTurn")) { trigHandler.registerThisTurnDelayedTrigger(delTrig); } else if (mapParams.containsKey("NextTurn")) { - game.getCleanup().addUntil(() -> trigHandler.registerThisTurnDelayedTrigger(delTrig)); + trigHandler.registerNextTurnDelayedTrigger(delTrig); } else if (mapParams.containsKey("UpcomingTurn")) { game.getCleanup().addUntil(() -> trigHandler.registerDelayedTrigger(delTrig)); } else { diff --git a/forge-game/src/main/java/forge/game/trigger/TriggerHandler.java b/forge-game/src/main/java/forge/game/trigger/TriggerHandler.java index 5c475599cca2..ff6ba36707c8 100644 --- a/forge-game/src/main/java/forge/game/trigger/TriggerHandler.java +++ b/forge-game/src/main/java/forge/game/trigger/TriggerHandler.java @@ -46,6 +46,7 @@ public class TriggerHandler { private final List activeTriggers = new ArrayList<>(); private final List delayedTriggers = new ArrayList<>(); + private final List nextTurnDelayedTriggers = new ArrayList<>(); private final List thisTurnDelayedTriggers = new ArrayList<>(); private final ListMultimap playerDefinedDelayedTriggers = ArrayListMultimap.create(); private final List waitingTriggers = new ArrayList<>(); @@ -59,8 +60,24 @@ public final void registerDelayedTrigger(final Trigger trig) { delayedTriggers.add(trig); } + public final List getScheduledDelayedTriggers() { + List result = new ArrayList<>(delayedTriggers); + result.addAll(nextTurnDelayedTriggers); + return result; + } + + public final void registerNextTurnDelayedTrigger(final Trigger trig) { + nextTurnDelayedTriggers.add(trig); + game.getCleanup().addUntil(() -> { + if (nextTurnDelayedTriggers.remove(trig)) { + registerThisTurnDelayedTrigger(trig); + } + }); + } + public final void clearDelayedTrigger() { delayedTriggers.clear(); + nextTurnDelayedTriggers.clear(); } public final void registerThisTurnDelayedTrigger(final Trigger trig) { @@ -74,13 +91,8 @@ public final void clearThisTurnDelayedTrigger() { } public final void clearDelayedTrigger(final Card card) { - final List deltrigs = new ArrayList<>(delayedTriggers); - - for (final Trigger trigger : deltrigs) { - if (trigger.getHostCard().equals(card)) { - delayedTriggers.remove(trigger); - } - } + delayedTriggers.removeIf(trigger -> trigger.getHostCard().equals(card)); + nextTurnDelayedTriggers.removeIf(trigger -> trigger.getHostCard().equals(card)); } public final void registerPlayerDefinedDelayedTrigger(final Player player, final Trigger trig) { @@ -591,13 +603,9 @@ public List getActiveTrigger(final TriggerType mode, final Map lost = new ArrayList<>(delayedTriggers); - for (Trigger t : lost) { - // CR 800.4d trigger controller lost game - if (p.equals(t.getSpawningAbility().getActivatingPlayer())) { - delayedTriggers.remove(t); - } - } + // CR 800.4d trigger controller lost game + delayedTriggers.removeIf(t -> p.equals(t.getSpawningAbility().getActivatingPlayer())); + nextTurnDelayedTriggers.removeIf(t -> p.equals(t.getSpawningAbility().getActivatingPlayer())); // run all ChangesZone runWaitingTriggers(); } diff --git a/forge-gui-desktop/src/test/java/forge/ai/simulation/OnePlaySafetyCheckerTest.java b/forge-gui-desktop/src/test/java/forge/ai/simulation/OnePlaySafetyCheckerTest.java index 5e33acff930b..769c4db29d25 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/simulation/OnePlaySafetyCheckerTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/simulation/OnePlaySafetyCheckerTest.java @@ -10,6 +10,7 @@ import forge.ai.PlayerControllerAi; import forge.game.Game; import forge.game.card.Card; +import forge.game.card.CounterEnumType; import forge.game.player.Player; import forge.game.spellability.Spell; import forge.game.spellability.SpellAbility; @@ -147,6 +148,131 @@ public void testNormalAiAllowsExpectedCardCost() { AssertJUnit.assertEquals("Dark Ritual", choices.get(0).getHostCard().getName()); } + @Test + public void testAllowsExplicitDiscardCost() { + Game game = initAndCreateGame(true); + Player ai = game.getPlayers().get(1); + + addCards("Swamp", 3, ai); + Card phantasmagorian = addCardToZone("Phantasmagorian", ai, ZoneType.Graveyard); + addCardToZone("Griselbrand", ai, ZoneType.Hand); + addCardToZone("Jin-Gitaxias, Core Augur", ai, ZoneType.Hand); + addCardToZone("Elesh Norn, Grand Cenobite", ai, ZoneType.Hand); + moveToMain2(game, ai); + + SpellAbility ability = phantasmagorian.getSpellAbilities().stream() + .filter(SpellAbility::isActivatedAbility).findFirst().orElseThrow(); + ability.setActivatingPlayer(ai); + AssertJUnit.assertTrue("The safety check should evaluate the play from its post-cost state", + OnePlaySafetyChecker.isAcceptable(ai, ability)); + } + + @Test + public void testAllowsScheduledGraveyardReturn() { + Game game = initAndCreateGame(true); + Player ai = game.getPlayers().get(1); + + addCards("Swamp", 3, ai); + Card familiar = addCard("Nine-Lives Familiar", ai); + familiar.setCounters(CounterEnumType.REVIVAL, 1); + Card murder = addCardToZone("Murder", ai, ZoneType.Hand); + moveToMain2(game, ai); + + SpellAbility ability = murder.getFirstSpellAbility(); + ability.setActivatingPlayer(ai); + ability.getTargets().add(familiar); + AssertJUnit.assertTrue("The Familiar is scheduled to return from the graveyard", + OnePlaySafetyChecker.isAcceptable(ai, ability)); + } + + @Test + public void testAllowsDelayedHandReturn() { + Game game = initAndCreateGame(true); + Player ai = game.getPlayers().get(1); + + addCards("Mountain", 2, ai); + Card bliss = addCardToZone("Ignorant Bliss", ai, ZoneType.Hand); + addCardToZone("Shivan Dragon", ai, ZoneType.Hand); + addCardToZone("Serra Angel", ai, ZoneType.Hand); + fillLibrary(ai, 1); + moveToMain2(game, ai); + + SpellAbility ability = bliss.getFirstSpellAbility(); + ability.setActivatingPlayer(ai); + AssertJUnit.assertTrue("Cards exiled by Ignorant Bliss return before its delayed draw", + OnePlaySafetyChecker.isAcceptable(ai, ability)); + } + + @Test + public void testAllowsMemoryJarTemporaryHands() { + Game game = initAndCreateGame(true); + Player ai = game.getPlayers().get(1); + Player opponent = game.getPlayers().get(0); + + Card jar = addCard("Memory Jar", ai); + addCardToZone("Shivan Dragon", ai, ZoneType.Hand); + addCardToZone("Serra Angel", ai, ZoneType.Hand); + addCardToZone("Forest", opponent, ZoneType.Hand); + addCardToZone("Island", opponent, ZoneType.Hand); + fillLibrary(ai, 10); + fillLibrary(opponent, 10); + moveToMain2(game, ai); + + SpellAbility ability = jar.getSpellAbilities().stream() + .filter(SpellAbility::isActivatedAbility).findFirst().orElseThrow(); + ability.setActivatingPlayer(ai); + AssertJUnit.assertTrue("Restoring the original hands should not make the activation unsafe", + OnePlaySafetyChecker.isAcceptable(ai, ability)); + } + + @Test + public void testAllowsNextTurnDelayedDraw() { + Game game = initAndCreateGame(true); + Player ai = game.getPlayers().get(1); + + addCards("Island", 2, ai); + Card legacy = addCardToZone("Lat-Nam's Legacy", ai, ZoneType.Hand); + addCardToZone("Runeclaw Bear", ai, ZoneType.Hand); + fillLibrary(ai, 2); + moveToMain2(game, ai); + + SpellAbility ability = legacy.getFirstSpellAbility(); + ability.setActivatingPlayer(ai); + AssertJUnit.assertTrue("The shuffled card is repaid by the next-turn draw", + OnePlaySafetyChecker.isAcceptable(ai, ability)); + } + + @Test + public void testDelayedDrawDoesNotExcuseImmediateLethalTrigger() { + AssertJUnit.assertFalse("A next-turn draw must not excuse an immediate loss", + evaluateBaubleAtOneLife("Disciple of the Vault")); + } + + @Test + public void testRejectsLethalNextTurnDelayedDraw() { + AssertJUnit.assertFalse("The scheduled draw is lethal while Nekusar remains in play", + evaluateBaubleAtOneLife("Nekusar, the Mindrazer")); + } + + private boolean evaluateBaubleAtOneLife(String opponentPermanent) { + Game game = initAndCreateGame(true); + Player ai = game.getPlayers().get(1); + Player opponent = game.getPlayers().get(0); + ai.setLife(1, null); + + Card bauble = addCard("Urza's Bauble", ai); + addCard(opponentPermanent, opponent); + addCardToZone("Forest", opponent, ZoneType.Hand); + fillLibrary(ai, 1); + moveToMain2(game, ai); + + SpellAbility ability = bauble.getSpellAbilities().stream() + .filter(SpellAbility::isActivatedAbility).findFirst().orElseThrow(); + ability.setActivatingPlayer(ai); + ability.getTargets().add(opponent); + return OnePlaySafetyChecker.isAcceptable(ai, ability); + } + @Test public void testAllowsWideBoardPhasingRescue() { Game game = initAndCreateGame(true);