Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AI Attack & getSpellAbilityToPlay Timeout #6577

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e2c37d1
AI Attack Timeout
kevlahnota Nov 14, 2024
8dbb648
add user setting for AI Timeout
kevlahnota Nov 14, 2024
66c08ab
try to fix unsupportedoperation
kevlahnota Nov 14, 2024
37eed29
try this multimap for manapool
kevlahnota Nov 15, 2024
a988261
check for stack only
kevlahnota Nov 15, 2024
8a1d25e
fix failing test
kevlahnota Nov 15, 2024
fc85623
better to use a linkedqueue
kevlahnota Nov 15, 2024
4936fd3
remove comment, seems it works fine. need to test on android.
kevlahnota Nov 15, 2024
a094149
remove redundant check for can cast timing
kevlahnota Nov 15, 2024
b9a56f0
revert multimap, needs better implementation for this
kevlahnota Nov 15, 2024
d9c4c81
Merge branch 'master' into AI_ATTACK_TIMEOUT
kevlahnota Nov 15, 2024
f52b7ab
create ConcurrentMultiMap, remove unused map
kevlahnota Nov 15, 2024
5c46048
removed unused import
kevlahnota Nov 15, 2024
7c35968
try to fix ConcurrentModificationException on FCollection -> addAll
kevlahnota Nov 15, 2024
548f97a
dumb check for AI
kevlahnota Nov 15, 2024
acfde12
Merge branch 'master' into AI_ATTACK_TIMEOUT
kevlahnota Nov 16, 2024
0247acb
Merge branch 'master' into AI_ATTACK_TIMEOUT
kevlahnota Nov 16, 2024
cb64567
Merge branch 'master' into AI_ATTACK_TIMEOUT
kevlahnota Nov 16, 2024
54266e8
Merge branch 'master' into AI_ATTACK_TIMEOUT
kevlahnota Nov 16, 2024
1261332
Update PlayEffect.java
kevlahnota Nov 16, 2024
f72ab6f
use removeall
kevlahnota Nov 16, 2024
6c847e0
refactor AiCardMemory
kevlahnota Nov 16, 2024
f6d2d42
use anymatch
kevlahnota Nov 16, 2024
5dc84a6
remove threadsafeIterable
kevlahnota Nov 17, 2024
619130b
remove unused import
kevlahnota Nov 17, 2024
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
288 changes: 151 additions & 137 deletions forge-ai/src/main/java/forge/ai/AiAttackController.java

Large diffs are not rendered by default.

180 changes: 113 additions & 67 deletions forge-ai/src/main/java/forge/ai/AiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,17 @@
import forge.game.zone.ZoneType;
import forge.item.PaperCard;
import forge.util.Aggregates;
import forge.util.CollectionUtil;
import forge.util.ComparatorUtil;
import forge.util.Expressions;
import forge.util.MyRandom;
import io.sentry.Breadcrumb;
import io.sentry.Sentry;

import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;

/**
* <p>
Expand All @@ -90,6 +94,7 @@ public class AiController {
private SpellAbilityPicker simPicker;
private int lastAttackAggression;
private boolean useLivingEnd;
private List<SpellAbility> skipped;

public AiController(final Player computerPlayer, final Game game0) {
player = computerPlayer;
Expand Down Expand Up @@ -399,10 +404,27 @@ private boolean checkETBEffectsPreparedCard(final Card card, final SpellAbility
private static List<SpellAbility> getPlayableCounters(final CardCollection l) {
final List<SpellAbility> spellAbility = Lists.newArrayList();
for (final Card c : l) {
for (final SpellAbility sa : c.getNonManaAbilities()) {
// Check if this AF is a Counterspell
if (sa.getApi() == ApiType.Counter) {
spellAbility.add(sa);
if (c.isForetold() && c.getAlternateState() != null) {
try {
for (final SpellAbility sa : c.getAlternateState().getNonManaAbilities()) {
// Check if this AF is a Counterspell
if (sa.getApi() == ApiType.Counter) {
spellAbility.add(sa);
} else {
if (sa.getApi() != null && sa.getApi().toString().contains("Foretell") && c.getAlternateState().getName().equalsIgnoreCase("Saw It Coming"))
spellAbility.add(sa);
}
}
} catch (Exception e) {
// facedown and alternatestate counters should be accessible
e.printStackTrace();
}
} else {
for (final SpellAbility sa : c.getNonManaAbilities()) {
// Check if this AF is a Counterspell
if (sa.getApi() == ApiType.Counter) {
spellAbility.add(sa);
}
}
}
}
Expand Down Expand Up @@ -1332,9 +1354,7 @@ public void declareAttackers(Player attacker, Combat combat) {

for (final Card element : combat.getAttackers()) {
// tapping of attackers happens after Propaganda is paid for
final StringBuilder sb = new StringBuilder();
sb.append("Computer just assigned ").append(element.getName()).append(" as an attacker.");
Log.debug(sb.toString());
Log.debug("Computer just assigned " + element.getName() + " as an attacker.");
}
}

Expand Down Expand Up @@ -1378,7 +1398,7 @@ public List<SpellAbility> chooseSpellAbilityToPlay() {
if (landsWannaPlay != null && !landsWannaPlay.isEmpty()) {
// TODO search for other land it might want to play?
Card land = chooseBestLandToPlay(landsWannaPlay);
if ((!player.canLoseLife() || player.cantLoseForZeroOrLessLife() || ComputerUtil.getDamageFromETB(player, land) < player.getLife())
if (land != null && (!player.canLoseLife() || player.cantLoseForZeroOrLessLife() || ComputerUtil.getDamageFromETB(player, land) < player.getLife())
&& (!game.getPhaseHandler().is(PhaseType.MAIN1) || !isSafeToHoldLandDropForMain2(land))) {
final List<SpellAbility> abilities = land.getAllPossibleAbilities(player, true);
// skip non Land Abilities
Expand Down Expand Up @@ -1511,6 +1531,13 @@ private boolean isSafeToHoldLandDropForMain2(Card landToPlay) {
}

private SpellAbility getSpellAbilityToPlay() {
if (skipped != null) {
//FIXME: this is for failed SA to skip temporarily, don't know why AI computation for mana fails, maybe due to auto mana compute?
for (SpellAbility sa : skipped) {
//System.out.println("Unskip: " + sa.toString() + " (" + sa.getHostCard().getName() + ").");
sa.setSkip(false);
}
}
CardCollection cards = ComputerUtilAbility.getAvailableCards(game, player);
cards = ComputerUtilCard.dedupeCards(cards);
List<SpellAbility> saList = Lists.newArrayList();
Expand Down Expand Up @@ -1558,6 +1585,10 @@ private SpellAbility getSpellAbilityToPlay() {
// TODO allow when experimental profile?
return spellAbility.isLandAbility() || (spellAbility.getHostCard() != null && ComputerUtilCard.isCardRemAIDeck(spellAbility.getHostCard()));
});
//removed skipped SA
skipped = Lists.newArrayList(Iterables.filter(saList, SpellAbility::isSkip));
if (!skipped.isEmpty())
saList.removeAll(skipped);
//update LivingEndPlayer
useLivingEnd = Iterables.any(player.getZone(ZoneType.Library), CardPredicates.nameEquals("Living End"));

Expand All @@ -1574,79 +1605,94 @@ private SpellAbility chooseSpellAbilityToPlayFromList(final List<SpellAbility> a
if (all == null || all.isEmpty())
return null;

try {
all.sort(ComputerUtilAbility.saEvaluator); // put best spells first
ComputerUtilAbility.sortCreatureSpells(all);
} catch (IllegalArgumentException ex) {
System.err.println(ex.getMessage());
String assertex = ComparatorUtil.verifyTransitivity(ComputerUtilAbility.saEvaluator, all);
Sentry.captureMessage(ex.getMessage() + "\nAssertionError [verifyTransitivity]: " + assertex);
}
//avoid ComputerUtil.aiLifeInDanger in loops as it slows down a lot.. call this outside loops will generally be fast...
boolean isLifeInDanger = useLivingEnd && ComputerUtil.aiLifeInDanger(player, true, 0);
List<CompletableFuture<Integer>> futures = new ArrayList<>();
Queue<SpellAbility> spells = new ConcurrentLinkedQueue<>();
for (final SpellAbility sa : ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player)) {
// Don't add Counterspells to the "normal" playcard lookups
if (skipCounter && sa.getApi() == ApiType.Counter) {
continue;
}
futures.add(CompletableFuture.supplyAsync(()-> {
// Don't add Counterspells to the "normal" playcard lookups
if (skipCounter && sa.getApi() == ApiType.Counter) {
return 0;
}

if (sa.getHostCard().hasKeyword(Keyword.STORM)
&& sa.getApi() != ApiType.Counter // AI would suck at trying to deliberately proc a Storm counterspell
&& player.getZone(ZoneType.Hand).contains(Predicates.not(Predicates.or(CardPredicates.Presets.LANDS, CardPredicates.hasKeyword("Storm"))))) {
if (game.getView().getStormCount() < this.getIntProperty(AiProps.MIN_COUNT_FOR_STORM_SPELLS)) {
// skip evaluating Storm unless we reached the minimum Storm count
continue;
if (sa.getHostCard().hasKeyword(Keyword.STORM)
&& sa.getApi() != ApiType.Counter // AI would suck at trying to deliberately proc a Storm counterspell
&& player.getZone(ZoneType.Hand).contains(Predicates.not(Predicates.or(CardPredicates.Presets.LANDS, CardPredicates.hasKeyword("Storm"))))) {
if (game.getView().getStormCount() < this.getIntProperty(AiProps.MIN_COUNT_FOR_STORM_SPELLS)) {
// skip evaluating Storm unless we reached the minimum Storm count
return 0;
}
}
}
// living end AI decks
// TODO: generalize the implementation so that superfluous logic-specific checks for life, library size, etc. aren't needed
AiPlayDecision aiPlayDecision = AiPlayDecision.CantPlaySa;
if (useLivingEnd) {
if (sa.isCycling() && sa.canCastTiming(player) && player.getCardsIn(ZoneType.Library).size() >= 10) {
if (ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) {
if (sa.getPayCosts() != null && sa.getPayCosts().hasSpecificCostType(CostPayLife.class)
&& !player.cantLoseForZeroOrLessLife()
&& player.getLife() <= sa.getPayCosts().getCostPartByType(CostPayLife.class).getAbilityAmount(sa) * 2) {
aiPlayDecision = AiPlayDecision.CantAfford;
// living end AI decks
// TODO: generalize the implementation so that superfluous logic-specific checks for life, library size, etc. aren't needed
AiPlayDecision aiPlayDecision = AiPlayDecision.CantPlaySa;
if (useLivingEnd) {
if (sa.isCycling() && sa.canCastTiming(player) && player.getCardsIn(ZoneType.Library).size() >= 10) {
if (ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) {
if (sa.getPayCosts() != null && sa.getPayCosts().hasSpecificCostType(CostPayLife.class)
&& !player.cantLoseForZeroOrLessLife()
&& player.getLife() <= sa.getPayCosts().getCostPartByType(CostPayLife.class).getAbilityAmount(sa) * 2) {
aiPlayDecision = AiPlayDecision.CantAfford;
} else {
aiPlayDecision = AiPlayDecision.WillPlay;
}
}
} else if (sa.getHostCard().hasKeyword(Keyword.CASCADE)) {
if (isLifeInDanger) { //needs more tune up for certain conditions
aiPlayDecision = player.getCreaturesInPlay().size() >= 4 ? AiPlayDecision.CantPlaySa : AiPlayDecision.WillPlay;
} else if (CardLists.filter(player.getZone(ZoneType.Graveyard).getCards(), CardPredicates.Presets.CREATURES).size() > 4) {
if (player.getCreaturesInPlay().size() >= 4) // it's good minimum
return 0;
else if (!sa.getHostCard().isPermanent() && sa.canCastTiming(player) && ComputerUtilCost.canPayCost(sa, player, sa.isTrigger()))
aiPlayDecision = AiPlayDecision.WillPlay;// needs tuneup for bad matchups like reanimator and other things to check on opponent graveyard
} else {
aiPlayDecision = AiPlayDecision.WillPlay;
return 0;
}
}
} else if (sa.getHostCard().hasKeyword(Keyword.CASCADE)) {
if (isLifeInDanger) { //needs more tune up for certain conditions
aiPlayDecision = player.getCreaturesInPlay().size() >= 4 ? AiPlayDecision.CantPlaySa : AiPlayDecision.WillPlay;
} else if (CardLists.filter(player.getZone(ZoneType.Graveyard).getCards(), CardPredicates.Presets.CREATURES).size() > 4) {
if (player.getCreaturesInPlay().size() >= 4) // it's good minimum
continue;
else if (!sa.getHostCard().isPermanent() && sa.canCastTiming(player) && ComputerUtilCost.canPayCost(sa, player, sa.isTrigger()))
aiPlayDecision = AiPlayDecision.WillPlay;// needs tuneup for bad matchups like reanimator and other things to check on opponent graveyard
} else {
continue;
}
}
}

sa.setActivatingPlayer(player, true);
SpellAbility root = sa.getRootAbility();
sa.setActivatingPlayer(player, true);
SpellAbility root = sa.getRootAbility();

if (root.isSpell() || root.isTrigger() || root.isReplacementAbility()) {
sa.setLastStateBattlefield(game.getLastStateBattlefield());
sa.setLastStateGraveyard(game.getLastStateGraveyard());
}
//override decision for living end player
AiPlayDecision opinion = useLivingEnd && AiPlayDecision.WillPlay.equals(aiPlayDecision) ? aiPlayDecision : canPlayAndPayFor(sa);
if (root.isSpell() || root.isTrigger() || root.isReplacementAbility()) {
sa.setLastStateBattlefield(game.getLastStateBattlefield());
sa.setLastStateGraveyard(game.getLastStateGraveyard());
}
//override decision for living end player
AiPlayDecision opinion = useLivingEnd && AiPlayDecision.WillPlay.equals(aiPlayDecision) ? aiPlayDecision : canPlayAndPayFor(sa);

// reset LastStateBattlefield
sa.clearLastState();
// PhaseHandler ph = game.getPhaseHandler();
// System.out.printf("Ai thinks '%s' of %s -> %s @ %s %s >>> \n", opinion, sa.getHostCard(), sa, Lang.getPossesive(ph.getPlayerTurn().getName()), ph.getPhase());
// reset LastStateBattlefield
sa.clearLastState();
// PhaseHandler ph = game.getPhaseHandler();
// System.out.printf("Ai thinks '%s' of %s -> %s @ %s %s >>> \n", opinion, sa.getHostCard(), sa, Lang.getPossesive(ph.getPlayerTurn().getName()), ph.getPhase());

if (opinion != AiPlayDecision.WillPlay)
continue;
if (opinion != AiPlayDecision.WillPlay)
return 0;

return sa;
spells.add(sa);
return 0;
}));
}
//timeout 5 seconds? even the AI don't acquire all, there should be SA to cast if valid
CompletableFuture<?>[] futuresArray = futures.toArray(new CompletableFuture<?>[0]);
tool4ever marked this conversation as resolved.
Show resolved Hide resolved
CompletableFuture.allOf(futuresArray).completeOnTimeout(null, player.getTimeout(), TimeUnit.SECONDS).join();
futures.clear();

if (!spells.isEmpty()) {
List<SpellAbility> spellAbilities = new ArrayList<>(spells);
if (spellAbilities.size() == 1)
return spellAbilities.get(0);
try {
spellAbilities.sort(ComputerUtilAbility.saEvaluator); // put best spells first
ComputerUtilAbility.sortCreatureSpells(spellAbilities);
} catch (IllegalArgumentException ex) {
System.err.println(ex.getMessage());
String assertex = ComparatorUtil.verifyTransitivity(ComputerUtilAbility.saEvaluator, spellAbilities);
Sentry.captureMessage(ex.getMessage() + "\nAssertionError [verifyTransitivity]: " + assertex);
}
return spellAbilities.get(0);
}

return null;
}

Expand Down Expand Up @@ -2214,7 +2260,7 @@ public List<SpellAbility> orderPlaySa(List<SpellAbility> activePlayerSAs) {
result.addAll(activePlayerSAs);

//need to reverse because of magic stack
Collections.reverse(result);
CollectionUtil.reverse(result);
return result;
}

Expand Down
5 changes: 3 additions & 2 deletions forge-ai/src/main/java/forge/ai/AiCostDecision.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import forge.game.spellability.SpellAbilityStackInstance;
import forge.game.zone.ZoneType;
import forge.util.Aggregates;
import forge.util.CollectionUtil;
import forge.util.TextUtil;
import forge.util.collect.FCollectionView;
import org.apache.commons.lang3.ObjectUtils;
Expand Down Expand Up @@ -161,7 +162,7 @@ public PaymentDecision visit(CostPromiseGift cost) {
List<Player> res = cost.getPotentialPlayers(player, ability);
// I should only choose one of these right?
// TODO Choose the "worst" player.
Collections.shuffle(res);
CollectionUtil.shuffle(res);

return PaymentDecision.players(res.subList(0, 1));
}
Expand All @@ -185,7 +186,7 @@ public PaymentDecision visit(CostExile cost) {
CardCollection chosen = new CardCollection();

CardLists.sortByCmcDesc(valid);
Collections.reverse(valid);
CollectionUtil.reverse(valid);

int totalCMC = 0;
for (Card card : valid) {
Expand Down
24 changes: 17 additions & 7 deletions forge-ai/src/main/java/forge/ai/ComputerUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
import forge.game.zone.Zone;
import forge.game.zone.ZoneType;
import forge.util.Aggregates;
import forge.util.CollectionUtil;
import forge.util.MyRandom;
import forge.util.TextUtil;
import forge.util.collect.FCollection;
Expand All @@ -87,6 +88,8 @@ public static boolean handlePlayingSpellAbility(final Player ai, SpellAbility sa
}
public static boolean handlePlayingSpellAbility(final Player ai, SpellAbility sa, final Game game, Runnable chooseTargets) {
final Card source = sa.getHostCard();
final Card host = sa.getHostCard();
final Zone hz = host.isCopiedSpell() ? null : host.getZone();
source.setSplitStateToPlayAbility(sa);

if (sa.isSpell() && !source.isCopiedSpell()) {
Expand Down Expand Up @@ -144,8 +147,15 @@ public static boolean handlePlayingSpellAbility(final Player ai, SpellAbility sa
return true;
}
}
//Should not arrive here
System.out.println("AI failed to play " + sa.getHostCard());
// FIXME: Should not arrive here, though the card seems to be stucked on stack zone and invalidated and nowhere to be found, try to put back to original zone and maybe try to cast again if possible at later time?
System.out.println("[" + sa.getActivatingPlayer() + "] AI failed to play " + sa.getHostCard() + " [" + sa.getHostCard().getZone() + "]");
sa.setSkip(true);
if (host != null && hz != null && hz.is(ZoneType.Stack)) {
Card c = game.getAction().moveTo(hz.getZoneType(), host, null, null);
for (SpellAbility csa : c.getSpellAbilities()) {
csa.setSkip(true);
tool4ever marked this conversation as resolved.
Show resolved Hide resolved
}
}
return false;
}

Expand Down Expand Up @@ -673,7 +683,7 @@ public static CardCollection chooseCollectEvidence(final Player ai, CostCollectE

// FIXME: This is suboptimal, maybe implement a single comparator that'll take care of all of this?
CardLists.sortByCmcDesc(typeList);
Collections.reverse(typeList);
CollectionUtil.reverse(typeList);


// TODO AI needs some improvements here
Expand Down Expand Up @@ -727,7 +737,7 @@ public static CardCollection chooseExileFromList(final Player ai, CardCollection

// FIXME: This is suboptimal, maybe implement a single comparator that'll take care of all of this?
CardLists.sortByCmcDesc(typeList);
Collections.reverse(typeList);
CollectionUtil.reverse(typeList);
typeList.sort((a, b) -> {
if (!a.isInPlay() && b.isInPlay()) return -1;
else if (!b.isInPlay() && a.isInPlay()) return 1;
Expand Down Expand Up @@ -757,7 +767,7 @@ public static CardCollection choosePutToLibraryFrom(final Player ai, final ZoneT
final CardCollection list = new CardCollection();

if (zone != ZoneType.Hand) {
Collections.reverse(typeList);
CollectionUtil.reverse(typeList);
}

for (int i = 0; i < amount; i++) {
Expand Down Expand Up @@ -808,7 +818,7 @@ public static CardCollection chooseTapTypeAccumulatePower(final Player ai, final
typeList.remove(activate);
}
ComputerUtilCard.sortByEvaluateCreature(typeList);
Collections.reverse(typeList);
CollectionUtil.reverse(typeList);

final CardCollection tapList = new CardCollection();

Expand Down Expand Up @@ -1760,7 +1770,7 @@ public static List<GameObject> predictThreatenedObjects(final Player ai, final S

// align threatened with resolve order
// matters if stack contains multiple activations (e.g. Temur Sabertooth)
Collections.reverse(objects);
CollectionUtil.reverse(objects);
return objects;
}

Expand Down
Loading
Loading