Simulated AI: Fix land pruning logic and skip invalid targets.

This change improves Simulated AI logic by eliminating many unnecessary simulations, particularly due to:
  - Invalid targets involving counterspells countering themselves (including modal spells).
  - Pruning identical land drop decisions.

Tests are added to cover these cases.

Some core logic is changed, in particular, SpellAbility.canTarget() was not rejecting self-targeting for counterspells. This was likely being done at a higher level somewhere (e.g. in UI code for choosing targets for the human player or AI-specific code for non-simulated AI).

Additionally, a convenience SpellAbility.hasLegalTargets() method is added, from the logic that was previously in AIController.java, so that it can be re-used by the simulation AI code.

A few small style clean ups are included in the code being changed.
This commit is contained in:
asvitkine
2022-12-22 21:40:52 -07:00
parent f59128a6eb
commit 56b22ee73a
6 changed files with 159 additions and 46 deletions

View File

@@ -903,13 +903,8 @@ public class AiController {
if (sa instanceof SpellPermanent) { if (sa instanceof SpellPermanent) {
return canPlayFromEffectAI((SpellPermanent)sa, false, true); return canPlayFromEffectAI((SpellPermanent)sa, false, true);
} }
if (sa.usesTargeting()) { if (sa.usesTargeting() && !sa.hasLegalTargets()) {
if (!sa.isTargetNumberValid() && sa.getTargetRestrictions().getNumCandidates(sa, true) == 0) { return AiPlayDecision.TargetingFailed;
return AiPlayDecision.TargetingFailed;
}
if (!StaticAbilityMustTarget.meetsMustTargetRestriction(sa)) {
return AiPlayDecision.TargetingFailed;
}
} }
if (sa instanceof Spell) { if (sa instanceof Spell) {
if (!player.cantLoseForZeroOrLessLife() && player.canLoseLife() && if (!player.cantLoseForZeroOrLessLife() && player.canLoseLife() &&

View File

@@ -1,6 +1,9 @@
package forge.ai.simulation; package forge.ai.simulation;
import forge.game.ability.ApiType;
import forge.game.ability.effects.CharmEffect;
import forge.game.spellability.LandAbility; import forge.game.spellability.LandAbility;
import forge.game.zone.ZoneType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
@@ -198,13 +201,10 @@ public class GameSimulator {
final SpellAbility playingSa = sa; final SpellAbility playingSa = sa;
simGame.copyLastState(); simGame.copyLastState();
boolean success = ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame, new Runnable() { boolean success = ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame, () -> {
@Override if (interceptor != null) {
public void run() { interceptor.announceX(playingSa);
if (interceptor != null) { interceptor.chooseTargets(playingSa, GameSimulator.this);
interceptor.announceX(playingSa);
interceptor.chooseTargets(playingSa, GameSimulator.this);
}
} }
}); });
if (!success) { if (!success) {

View File

@@ -16,7 +16,7 @@ import forge.game.spellability.AbilitySub;
import forge.game.spellability.SpellAbility; import forge.game.spellability.SpellAbility;
public class SpellAbilityChoicesIterator { public class SpellAbilityChoicesIterator {
private SimulationController controller; private final SimulationController controller;
private Iterator<int[]> modeIterator; private Iterator<int[]> modeIterator;
private int[] selectedModes; private int[] selectedModes;
@@ -34,11 +34,13 @@ public class SpellAbilityChoicesIterator {
Card selectedChoice; Card selectedChoice;
Score bestScoreForChoice = new Score(Integer.MIN_VALUE); Score bestScoreForChoice = new Score(Integer.MIN_VALUE);
} }
private ArrayList<ChoicePoint> choicePoints = new ArrayList<>(); private final ArrayList<ChoicePoint> choicePoints = new ArrayList<>();
private int incrementedCpIndex = 0; private int incrementedCpIndex = 0;
private int cpIndex = -1; private int cpIndex = -1;
private int evalDepth; private int evalDepth;
// Maps from filtered mode indexes to original ones.
private List<Integer> modesMap;
public SpellAbilityChoicesIterator(SimulationController controller) { public SpellAbilityChoicesIterator(SimulationController controller) {
this.controller = controller; this.controller = controller;
@@ -46,17 +48,26 @@ public class SpellAbilityChoicesIterator {
public List<AbilitySub> chooseModesForAbility(List<AbilitySub> choices, int min, int num, boolean allowRepeat) { public List<AbilitySub> chooseModesForAbility(List<AbilitySub> choices, int min, int num, boolean allowRepeat) {
if (modeIterator == null) { if (modeIterator == null) {
// TODO: Need to skip modes that are invalid (e.g. targets don't exist)! // Skip modes that don't have legal targets.
modesMap = new ArrayList<>();
int origIndex = -1;
for (AbilitySub sub : choices) {
origIndex++;
if (sub.usesTargeting() && !sub.hasLegalTargets()) {
continue;
}
modesMap.add(origIndex);
}
// TODO: Do we need to do something special to support cards that have extra costs // TODO: Do we need to do something special to support cards that have extra costs
// when choosing more modes, like Blessed Alliance? // when choosing more modes, like Blessed Alliance?
if (!allowRepeat) { if (!allowRepeat) {
modeIterator = CombinatoricsUtils.combinationsIterator(choices.size(), num); modeIterator = CombinatoricsUtils.combinationsIterator(modesMap.size(), num);
} else { } else {
// Note: When allowRepeat is true, it does result in many possibilities being tried. // Note: When allowRepeat is true, it does result in many possibilities being tried.
// We should ideally prune some of those at a higher level. // We should ideally prune some of those at a higher level.
modeIterator = new AllowRepeatModesIterator(choices.size(), min, num); modeIterator = new AllowRepeatModesIterator(modesMap.size(), min, num);
} }
selectedModes = modeIterator.next(); selectedModes = remapModes(modeIterator.next());
advancedToNextMode = true; advancedToNextMode = true;
} }
// Note: If modeIterator already existed, selectedModes would have been updated in advance(). // Note: If modeIterator already existed, selectedModes would have been updated in advance().
@@ -78,6 +89,13 @@ public class SpellAbilityChoicesIterator {
return result; return result;
} }
private int[] remapModes(int[] modes) {
for (int i = 0; i < modes.length; i++) {
modes[i] = modesMap.get(modes[i]);
}
return modes;
}
public Card chooseCard(CardCollection fetchList) { public Card chooseCard(CardCollection fetchList) {
cpIndex++; cpIndex++;
if (cpIndex >= choicePoints.size()) { if (cpIndex >= choicePoints.size()) {
@@ -86,8 +104,7 @@ public class SpellAbilityChoicesIterator {
ChoicePoint cp = choicePoints.get(cpIndex); ChoicePoint cp = choicePoints.get(cpIndex);
// Prune duplicates. // Prune duplicates.
HashSet<String> uniqueCards = new HashSet<>(); HashSet<String> uniqueCards = new HashSet<>();
for (int i = 0; i < fetchList.size(); i++) { for (Card card : fetchList) {
Card card = fetchList.get(i);
if (uniqueCards.add(card.getName()) && uniqueCards.size() == cp.nextChoice + 1) { if (uniqueCards.add(card.getName()) && uniqueCards.size() == cp.nextChoice + 1) {
cp.selectedChoice = card; cp.selectedChoice = card;
} }
@@ -137,14 +154,9 @@ public class SpellAbilityChoicesIterator {
evalDepth++; evalDepth++;
pushTarget = false; pushTarget = false;
} }
return;
} }
} }
public int[] getSelectModes() {
return selectedModes;
}
public boolean advance(Score lastScore) { public boolean advance(Score lastScore) {
cpIndex = -1; cpIndex = -1;
for (ChoicePoint cp : choicePoints) { for (ChoicePoint cp : choicePoints) {
@@ -195,7 +207,7 @@ public class SpellAbilityChoicesIterator {
doneEvaluating(bestScoreForMode); doneEvaluating(bestScoreForMode);
bestScoreForMode = new Score(Integer.MIN_VALUE); bestScoreForMode = new Score(Integer.MIN_VALUE);
if (modeIterator.hasNext()) { if (modeIterator.hasNext()) {
selectedModes = modeIterator.next(); selectedModes = remapModes(modeIterator.next());
advancedToNextMode = true; advancedToNextMode = true;
return true; return true;
} }
@@ -232,8 +244,8 @@ public class SpellAbilityChoicesIterator {
} }
private static class AllowRepeatModesIterator implements Iterator<int[]> { private static class AllowRepeatModesIterator implements Iterator<int[]> {
private int numChoices; private final int numChoices;
private int max; private final int max;
private int[] indexes; private int[] indexes;
public AllowRepeatModesIterator(int numChoices, int min, int max) { public AllowRepeatModesIterator(int numChoices, int min, int max) {

View File

@@ -39,6 +39,7 @@ public class SpellAbilityPicker {
private SpellAbilityChoicesIterator interceptor; private SpellAbilityChoicesIterator interceptor;
private Plan plan; private Plan plan;
private int numSimulations;
public SpellAbilityPicker(Game game, Player player) { public SpellAbilityPicker(Game game, Player player) {
this.game = game; this.game = game;
@@ -66,19 +67,7 @@ public class SpellAbilityPicker {
private List<SpellAbility> getCandidateSpellsAndAbilities() { private List<SpellAbility> getCandidateSpellsAndAbilities() {
CardCollection cards = ComputerUtilAbility.getAvailableCards(game, player); CardCollection cards = ComputerUtilAbility.getAvailableCards(game, player);
List<SpellAbility> all = ComputerUtilAbility.getSpellAbilities(cards, player); List<SpellAbility> all = ComputerUtilAbility.getSpellAbilities(cards, player);
CardCollection landsToPlay = ComputerUtilAbility.getAvailableLandsToPlay(game, player); HashMap<String, Card> landsDeDupe = new HashMap<>();
if (landsToPlay != null) {
HashMap<String, Card> landsDeDupe = new HashMap<>();
for (Card land : landsToPlay) {
Card previousLand = landsDeDupe.get(land.getName());
// Skip identical lands.
if (previousLand != null && previousLand.getZone() == land.getZone() && previousLand.getOwner() == land.getOwner()) {
continue;
}
landsDeDupe.put(land.getName(), land);
all.add(new LandAbility(land));
}
}
List<SpellAbility> candidateSAs = ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player); List<SpellAbility> candidateSAs = ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player);
int writeIndex = 0; int writeIndex = 0;
for (int i = 0; i < candidateSAs.size(); i++) { for (int i = 0; i < candidateSAs.size(); i++) {
@@ -86,6 +75,16 @@ public class SpellAbilityPicker {
if (sa.isManaAbility()) { if (sa.isManaAbility()) {
continue; continue;
} }
// Skip identical lands.
if (sa instanceof LandAbility) {
Card land = sa.getHostCard();
Card previousLand = landsDeDupe.get(sa.getHostCard().getName());
if (previousLand != null && previousLand.getZone() == land.getZone() &&
previousLand.getOwner() == land.getOwner()) {
continue;
}
landsDeDupe.put(land.getName(), land);
}
sa.setActivatingPlayer(player, true); sa.setActivatingPlayer(player, true);
AiPlayDecision opinion = canPlayAndPayForSim(sa); AiPlayDecision opinion = canPlayAndPayForSim(sa);
@@ -95,7 +94,7 @@ public class SpellAbilityPicker {
if (opinion != AiPlayDecision.WillPlay) if (opinion != AiPlayDecision.WillPlay)
continue; continue;
candidateSAs.set(writeIndex, sa); candidateSAs.set(writeIndex, sa);
writeIndex++; writeIndex++;
} }
candidateSAs.subList(writeIndex, candidateSAs.size()).clear(); candidateSAs.subList(writeIndex, candidateSAs.size()).clear();
@@ -353,7 +352,9 @@ public class SpellAbilityPicker {
if (!ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) { if (!ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) {
return AiPlayDecision.CantAfford; return AiPlayDecision.CantAfford;
} }
if (sa.usesTargeting() && !sa.hasLegalTargets()) {
return AiPlayDecision.TargetingFailed;
}
if (shouldWaitForLater(sa)) { if (shouldWaitForLater(sa)) {
return AiPlayDecision.AnotherTime; return AiPlayDecision.AnotherTime;
} }
@@ -375,10 +376,13 @@ public class SpellAbilityPicker {
final SpellAbilityChoicesIterator choicesIterator = new SpellAbilityChoicesIterator(controller); final SpellAbilityChoicesIterator choicesIterator = new SpellAbilityChoicesIterator(controller);
Score lastScore; Score lastScore;
do { do {
// TODO: MyRandom should be an instance on the game object, so that we could do
// simulations in parallel without messing up global state.
MyRandom.setRandom(new Random(randomSeedToUse)); MyRandom.setRandom(new Random(randomSeedToUse));
GameSimulator simulator = new GameSimulator(controller, game, player, phase); GameSimulator simulator = new GameSimulator(controller, game, player, phase);
simulator.setInterceptor(choicesIterator); simulator.setInterceptor(choicesIterator);
lastScore = simulator.simulateSpellAbility(sa); lastScore = simulator.simulateSpellAbility(sa);
numSimulations++;
if (lastScore.value > bestScore.value) { if (lastScore.value > bestScore.value) {
bestScore = lastScore; bestScore = lastScore;
} }
@@ -462,4 +466,8 @@ public class SpellAbilityPicker {
} }
return ComputerUtil.chooseSacrificeType(player, type, ability, ability.getTargetCard(), effect, amount, exclude); return ComputerUtil.chooseSacrificeType(player, type, ability, ability.getTargetCard(), effect, amount, exclude);
} }
public int getNumSimulations() {
return numSimulations;
}
} }

View File

@@ -1254,10 +1254,15 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
return false; return false;
} }
final TargetRestrictions tr = getTargetRestrictions(); final SpellAbility rootAbility = this.getRootAbility();
// 115.5. A spell or ability on the stack is an illegal target for itself.
if (rootAbility.isSpell() && rootAbility.getHostCard() == entity) {
return false;
}
// Restriction related to this ability // Restriction related to this ability
if (usesTargeting()) { if (usesTargeting()) {
final TargetRestrictions tr = getTargetRestrictions();
if (tr.isUniqueTargets() && getUniqueTargets().contains(entity)) if (tr.isUniqueTargets() && getUniqueTargets().contains(entity))
return false; return false;
@@ -1649,6 +1654,16 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
return targetRestrictions != null; return targetRestrictions != null;
} }
public boolean hasLegalTargets() {
if (!isTargetNumberValid() && getTargetRestrictions().getNumCandidates(this, true) == 0) {
return false;
}
if (!StaticAbilityMustTarget.meetsMustTargetRestriction(this)) {
return false;
}
return true;
}
public TargetRestrictions getTargetRestrictions() { public TargetRestrictions getTargetRestrictions() {
return targetRestrictions; return targetRestrictions;
} }

View File

@@ -483,4 +483,87 @@ public class SpellAbilityPickerSimulationTest extends SimulationTest {
AssertJUnit.assertEquals("Chaos Warp", sa.getHostCard().getName()); AssertJUnit.assertEquals("Chaos Warp", sa.getHostCard().getName());
AssertJUnit.assertEquals(expectedTarget, sa.getTargetCard()); AssertJUnit.assertEquals(expectedTarget, sa.getTargetCard());
} }
@Test
public void testNoSimulationsWhenNoTargets() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(1);
addCard("Island", p);
addCard("Island", p);
addCardToZone("Counterspell", p, ZoneType.Hand);
addCardToZone("Unsummon", p, ZoneType.Hand);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
game.getAction().checkStateEffects(true);
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
AssertJUnit.assertNull(sa);
AssertJUnit.assertEquals(0, picker.getNumSimulations());
}
@Test
public void testLandDropPruning() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(1);
addCardToZone("Island", p, ZoneType.Hand);
addCardToZone("Island", p, ZoneType.Hand);
addCardToZone("Island", p, ZoneType.Hand);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
game.getAction().checkStateEffects(true);
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
AssertJUnit.assertNotNull(sa);
// Only one land drop should be simulated, since the cards are identical.
AssertJUnit.assertEquals(1, picker.getNumSimulations());
}
@Test
public void testSpellCantTargetSelf() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(1);
Player opponent = game.getPlayers().get(0);
addCardToZone("Unsubstantiate", p, ZoneType.Hand);
addCard("Forest", p);
addCard("Island", p);
Card expectedTarget = addCard("Flying Men", opponent);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
game.getAction().checkStateEffects(true);
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
AssertJUnit.assertNotNull(sa);
AssertJUnit.assertEquals(expectedTarget, sa.getTargetCard());
// Only a single simulation expected (no target self).
AssertJUnit.assertEquals(1, picker.getNumSimulations());
}
@Test
public void testModalSpellCantTargetSelf() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(1);
Player opponent = game.getPlayers().get(0);
addCardToZone("Decisive Denial", p, ZoneType.Hand);
addCard("Forest", p);
addCard("Island", p);
addCard("Runeclaw Bear", p);
addCard("Flying Men", opponent);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
game.getAction().checkStateEffects(true);
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
AssertJUnit.assertNotNull(sa);
// Expected: Runeclaw Bear fights Flying Men
// Only a single simulation expected (no target self).
AssertJUnit.assertEquals(1, picker.getNumSimulations());
}
} }