Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Boolean> CHECKING = ThreadLocal.withInitial(() -> false);

Expand All @@ -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<Card> existingCommandCards = new HashSet<>(simulatedGame.getCardsIn(ZoneType.Command));
Set<Trigger> 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<Card> existingCommandCards, Set<Trigger> existingScheduledTriggers) {
Set<Trigger> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 22 additions & 14 deletions forge-game/src/main/java/forge/game/trigger/TriggerHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public class TriggerHandler {
private final List<Trigger> activeTriggers = new ArrayList<>();

private final List<Trigger> delayedTriggers = new ArrayList<>();
private final List<Trigger> nextTurnDelayedTriggers = new ArrayList<>();
private final List<Trigger> thisTurnDelayedTriggers = new ArrayList<>();
private final ListMultimap<Player, Trigger> playerDefinedDelayedTriggers = ArrayListMultimap.create();
private final List<TriggerWaiting> waitingTriggers = new ArrayList<>();
Expand All @@ -59,8 +60,24 @@ public final void registerDelayedTrigger(final Trigger trig) {
delayedTriggers.add(trig);
}

public final List<Trigger> getScheduledDelayedTriggers() {
List<Trigger> 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) {
Expand All @@ -74,13 +91,8 @@ public final void clearThisTurnDelayedTrigger() {
}

public final void clearDelayedTrigger(final Card card) {
final List<Trigger> 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) {
Expand Down Expand Up @@ -591,13 +603,9 @@ public List<Trigger> getActiveTrigger(final TriggerType mode, final Map<AbilityK
}

public void onPlayerLost(Player p) {
List<Trigger> 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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down