Merge pull request #2157 from asvitkine/sim_imprv

Simulated AI: Fix land pruning logic and skip invalid targets.
This commit is contained in:
Anthony Calosa
2022-12-26 11:43:34 +08:00
committed by GitHub
5 changed files with 188 additions and 40 deletions

View File

@@ -198,14 +198,11 @@ public class GameSimulator {
final SpellAbility playingSa = sa;
simGame.copyLastState();
boolean success = ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame, new Runnable() {
@Override
public void run() {
boolean success = ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame, () -> {
if (interceptor != null) {
interceptor.announceX(playingSa);
interceptor.chooseTargets(playingSa, GameSimulator.this);
}
}
});
if (!success) {
return new Score(Integer.MIN_VALUE);

View File

@@ -1,5 +1,6 @@
package forge.ai.simulation;
import forge.ai.ComputerUtilAbility;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
@@ -16,7 +17,7 @@ import forge.game.spellability.AbilitySub;
import forge.game.spellability.SpellAbility;
public class SpellAbilityChoicesIterator {
private SimulationController controller;
private final SimulationController controller;
private Iterator<int[]> modeIterator;
private int[] selectedModes;
@@ -34,11 +35,13 @@ public class SpellAbilityChoicesIterator {
Card selectedChoice;
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 cpIndex = -1;
private int evalDepth;
// Maps from filtered mode indexes to original ones.
private List<Integer> modesMap;
public SpellAbilityChoicesIterator(SimulationController controller) {
this.controller = controller;
@@ -46,17 +49,28 @@ public class SpellAbilityChoicesIterator {
public List<AbilitySub> chooseModesForAbility(List<AbilitySub> choices, int min, int num, boolean allowRepeat) {
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 (!ComputerUtilAbility.isFullyTargetable(sub)) {
continue;
}
modesMap.add(origIndex);
}
// TODO: Do we need to do something special to support cards that have extra costs
// when choosing more modes, like Blessed Alliance?
if (!allowRepeat) {
modeIterator = CombinatoricsUtils.combinationsIterator(choices.size(), num);
if (modesMap.isEmpty()) {
return null;
} else if (!allowRepeat) {
modeIterator = CombinatoricsUtils.combinationsIterator(modesMap.size(), num);
} else {
// Note: When allowRepeat is true, it does result in many possibilities being tried.
// 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;
}
// Note: If modeIterator already existed, selectedModes would have been updated in advance().
@@ -78,6 +92,13 @@ public class SpellAbilityChoicesIterator {
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) {
cpIndex++;
if (cpIndex >= choicePoints.size()) {
@@ -86,8 +107,7 @@ public class SpellAbilityChoicesIterator {
ChoicePoint cp = choicePoints.get(cpIndex);
// Prune duplicates.
HashSet<String> uniqueCards = new HashSet<>();
for (int i = 0; i < fetchList.size(); i++) {
Card card = fetchList.get(i);
for (Card card : fetchList) {
if (uniqueCards.add(card.getName()) && uniqueCards.size() == cp.nextChoice + 1) {
cp.selectedChoice = card;
}
@@ -137,14 +157,9 @@ public class SpellAbilityChoicesIterator {
evalDepth++;
pushTarget = false;
}
return;
}
}
public int[] getSelectModes() {
return selectedModes;
}
public boolean advance(Score lastScore) {
cpIndex = -1;
for (ChoicePoint cp : choicePoints) {
@@ -195,7 +210,7 @@ public class SpellAbilityChoicesIterator {
doneEvaluating(bestScoreForMode);
bestScoreForMode = new Score(Integer.MIN_VALUE);
if (modeIterator.hasNext()) {
selectedModes = modeIterator.next();
selectedModes = remapModes(modeIterator.next());
advancedToNextMode = true;
return true;
}
@@ -232,8 +247,8 @@ public class SpellAbilityChoicesIterator {
}
private static class AllowRepeatModesIterator implements Iterator<int[]> {
private int numChoices;
private int max;
private final int numChoices;
private final int max;
private int[] indexes;
public AllowRepeatModesIterator(int numChoices, int min, int max) {

View File

@@ -39,6 +39,7 @@ public class SpellAbilityPicker {
private SpellAbilityChoicesIterator interceptor;
private Plan plan;
private int numSimulations;
public SpellAbilityPicker(Game game, Player player) {
this.game = game;
@@ -66,19 +67,7 @@ public class SpellAbilityPicker {
private List<SpellAbility> getCandidateSpellsAndAbilities() {
CardCollection cards = ComputerUtilAbility.getAvailableCards(game, player);
List<SpellAbility> all = ComputerUtilAbility.getSpellAbilities(cards, player);
CardCollection landsToPlay = ComputerUtilAbility.getAvailableLandsToPlay(game, player);
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);
int writeIndex = 0;
for (int i = 0; i < candidateSAs.size(); i++) {
@@ -86,6 +75,16 @@ public class SpellAbilityPicker {
if (sa.isManaAbility()) {
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);
AiPlayDecision opinion = canPlayAndPayForSim(sa);
@@ -353,7 +352,9 @@ public class SpellAbilityPicker {
if (!ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) {
return AiPlayDecision.CantAfford;
}
if (!ComputerUtilAbility.isFullyTargetable(sa)) {
return AiPlayDecision.TargetingFailed;
}
if (shouldWaitForLater(sa)) {
return AiPlayDecision.AnotherTime;
}
@@ -375,10 +376,13 @@ public class SpellAbilityPicker {
final SpellAbilityChoicesIterator choicesIterator = new SpellAbilityChoicesIterator(controller);
Score lastScore;
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));
GameSimulator simulator = new GameSimulator(controller, game, player, phase);
simulator.setInterceptor(choicesIterator);
lastScore = simulator.simulateSpellAbility(sa);
numSimulations++;
if (lastScore.value > bestScore.value) {
bestScore = lastScore;
}
@@ -462,4 +466,8 @@ public class SpellAbilityPicker {
}
return ComputerUtil.chooseSacrificeType(player, type, ability, ability.getTargetCard(), effect, amount, exclude);
}
public int getNumSimulations() {
return numSimulations;
}
}

View File

@@ -1254,10 +1254,16 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
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.
// (This covers the spell case.)
if (rootAbility.isSpell() && rootAbility.getHostCard() == entity) {
return false;
}
// Restriction related to this ability
if (usesTargeting()) {
final TargetRestrictions tr = getTargetRestrictions();
if (tr.isUniqueTargets() && getUniqueTargets().contains(entity))
return false;

View File

@@ -483,4 +483,126 @@ public class SpellAbilityPickerSimulationTest extends SimulationTest {
AssertJUnit.assertEquals("Chaos Warp", sa.getHostCard().getName());
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());
}
@Test
public void testModalSpellNoTargetsForModeWithSubAbility() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(1);
addCardToZone("Temur Charm", p, ZoneType.Hand);
addCard("Forest", p);
addCard("Island", p);
addCard("Mountain", p);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
game.getAction().checkStateEffects(true);
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
picker.chooseSpellAbilityToPlay(null);
// Only mode "Creatures with power 3 or less cant block this turn" should be simulated.
AssertJUnit.assertEquals(1, picker.getNumSimulations());
}
@Test
public void testModalSpellNoTargetsForAnyModes() {
Game game = initAndCreateGame();
Player p = game.getPlayers().get(1);
addCardToZone("Drown in the Loch", p, ZoneType.Hand);
addCard("Swamp", p);
addCard("Island", p);
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
game.getAction().checkStateEffects(true);
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
picker.chooseSpellAbilityToPlay(null);
// TODO: Ideally, this would be 0 simulations, but we currently only determine there are no
// valid modes in SpellAbilityChoicesIterator, which runs already when we're simulating.
// Still, this test case exercises the code path and ensures we don't crash in this case.
AssertJUnit.assertEquals(1, picker.getNumSimulations());
}
}