Skip to content

Commit

Permalink
Improved AI tapland playing (Card-Forge#6751)
Browse files Browse the repository at this point in the history
marthinwurer authored Jan 8, 2025
1 parent 1981afc commit 4c7a55a
Showing 3 changed files with 131 additions and 29 deletions.
143 changes: 117 additions & 26 deletions forge-ai/src/main/java/forge/ai/AiController.java
Original file line number Diff line number Diff line change
@@ -23,11 +23,14 @@
import forge.ai.AiCardMemory.MemorySet;
import forge.ai.ability.ChangeZoneAi;
import forge.ai.ability.LearnAi;
import forge.ai.simulation.GameStateEvaluator;
import forge.ai.simulation.SpellAbilityPicker;
import forge.card.CardStateName;
import forge.card.CardType;
import forge.card.MagicColor;
import forge.card.mana.ManaAtom;
import forge.card.mana.ManaCost;
import forge.card.mana.ManaCostShard;
import forge.deck.Deck;
import forge.deck.DeckSection;
import forge.game.*;
@@ -71,6 +74,9 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import static forge.ai.ComputerUtilMana.getAvailableManaEstimate;
import static java.lang.Math.max;

/**
* <p>
* AiController class.
@@ -534,8 +540,10 @@ private Card chooseBestLandToPlay(CardCollection landList) {
landList = unreflectedLands;
}

//try to skip lands that enter the battlefield tapped
// try to skip lands that enter the battlefield tapped if we might want to play something this turn
if (!nonLandsInHand.isEmpty()) {
// get the tapped and non-tapped lands
CardCollection tappedLands = new CardCollection();
CardCollection nonTappedLands = new CardCollection();
for (Card land : landList) {
// check replacement effects if land would enter tapped or not
@@ -570,11 +578,48 @@ private Card chooseBestLandToPlay(CardCollection landList) {

nonTappedLands.add(land);
}

// if we have the choice, see if we can play an untapped land
if (!nonTappedLands.isEmpty()) {
landList = nonTappedLands;
// get the costs of the nonland cards in hand and the mana we have available.
// If adding one won't make something new castable, then pick a tapland.
int max_inc = 0;
for (Card c : nonTappedLands) {
max_inc = max(max_inc, c.getMaxManaProduced());
}
// If we have a lot of mana, prefer untapped lands.
// We're either topdecking or have drawn enough the tempo no longer matters.
int mana_available = getAvailableManaEstimate(player);
if (mana_available > 6) {
landList = nonTappedLands;
}
// check for lands with no mana abilities
else if (max_inc > 0) {
boolean found = false;
for (Card c : nonLandsInHand) {
// TODO make this work better with split cards and Monocolored Hybrid
ManaCost cost = c.getManaCost();
// check for incremental cmc
// check for X cost spells
if (cost.getCMC() == max_inc + mana_available ||
(cost.getShardCount(ManaCostShard.X) > 0 && cost.getCMC() >= mana_available)) {
found = true;
break;
}
}

if (found) {
landList = nonTappedLands;
}
}
}
}

// Early out if we only have one card left
if (landList.size() == 1) {
return landList.get(0);
}

// Choose first land to be able to play a one drop
if (player.getLandsInPlay().isEmpty()) {
CardCollection oneDrops = CardLists.filter(nonLandsInHand, CardPredicates.hasCMC(1));
@@ -595,40 +640,86 @@ private Card chooseBestLandToPlay(CardCollection landList) {
}
}

//play lands with a basic type that is needed the most
// play lands with a basic type and/or color that is needed the most
final CardCollectionView landsInBattlefield = player.getCardsIn(ZoneType.Battlefield);
final List<String> basics = Lists.newArrayList();

// what colors are available?
int[] counts = new int[6]; // in WUBRGC order

for (Card c : player.getCardsIn(ZoneType.Battlefield)) {
for (SpellAbility m: c.getManaAbilities()) {
m.setActivatingPlayer(c.getController());
for (AbilityManaPart mp : m.getAllManaParts()) {
for (String part : mp.mana(m).split(" ")) {
// TODO handle any
int index = ManaAtom.getIndexFromName(part);
if (index != -1) {
counts[index] += 1;
}
}
}
}
}

// what types can I go get?
int[] basic_counts = new int[5]; // in WUBRG order
for (final String name : MagicColor.Constant.BASIC_LANDS) {
if (!CardLists.getType(landList, name).isEmpty()) {
basics.add(name);
}
}
if (!basics.isEmpty()) {
// Which basic land is least available
int minSize = Integer.MAX_VALUE;
String minType = null;

for (String b : basics) {
for (int i = 0; i < MagicColor.Constant.BASIC_LANDS.size(); i++) {
String b = MagicColor.Constant.BASIC_LANDS.get(i);
final int num = CardLists.getType(landsInBattlefield, b).size();
if (num < minSize) {
minType = b;
minSize = num;
}
}
basic_counts[i] = num;
}
}
// pick the land with the best score.
// use the evaluation plus a modifier for each new color pip and basic type
Card toReturn = Aggregates.itemWithMax(IterableUtil.filter(landList, Card::hasPlayableLandFace),
(card -> {
// base score is for the evaluation score
int score = GameStateEvaluator.evaluateLand(card);
// add for new basic type
for (String cardType: card.getType()) {
int index = MagicColor.Constant.BASIC_LANDS.indexOf(cardType);
if (index != -1 && basic_counts[index] == 0) {
score += 25;
}
}

if (minType != null) {
landList = CardLists.getType(landList, minType);
}

// pick dual lands if available
if (landList.anyMatch(CardPredicates.NONBASIC_LANDS)) {
landList = CardLists.filter(landList, CardPredicates.NONBASIC_LANDS);
}
}
return ComputerUtilCard.getBestLandToPlayAI(landList);
}
// TODO handle fetchlands and what they can fetch for
// determine new color pips
int[] card_counts = new int[6]; // in WUBRGC order
for (SpellAbility m: card.getManaAbilities()) {
m.setActivatingPlayer(card.getController());
for (AbilityManaPart mp : m.getAllManaParts()) {
for (String part : mp.mana(m).split(" ")) {
// TODO handle any
int index = ManaAtom.getIndexFromName(part);
if (index != -1) {
card_counts[index] += 1;
}
}
}
}

// use 1 / x+1 for diminishing returns
// TODO use max pips of each color in the deck from deck statistics to weight this
for (int i = 0; i < card_counts.length; i++) {
int diff = (card_counts[i] * 50) / (counts[i] + 1);
score += diff;
}

// TODO utility lands only if we have enough to pay their costs
// TODO Tron lands and other lands that care about land counts

return score;
}));
return toReturn; }

// if return true, go to next phase
private SpellAbility chooseCounterSpell(final List<SpellAbility> possibleCounters) {
@@ -1047,7 +1138,7 @@ private boolean canPlaySpellWithoutBuyback(Card card, SpellAbility sa) {
neededMana = 0;
}

int hasMana = ComputerUtilMana.getAvailableManaEstimate(player, false);
int hasMana = getAvailableManaEstimate(player, false);
if (hasMana < neededMana - 1) {
return true;
}
@@ -1456,7 +1547,7 @@ private boolean isSafeToHoldLandDropForMain2(Card landToPlay) {
int minCMCInHand = Aggregates.min(inHand, Card::getCMC);
if (minCMCInHand == Integer.MAX_VALUE)
minCMCInHand = 0;
int predictedMana = ComputerUtilMana.getAvailableManaEstimate(player, true);
int predictedMana = getAvailableManaEstimate(player, true);

boolean canCastWithLandDrop = (predictedMana + 1 >= minCMCInHand) && minCMCInHand > 0 && !isTapLand;
boolean cantCastAnythingNow = predictedMana < minCMCInHand;
@@ -1979,7 +2070,7 @@ public int attemptToAssist(SpellAbility sa, int max, int request) {
}

// AI has decided to help. Now let's figure out how much they can help
int mana = ComputerUtilMana.getAvailableManaEstimate(player, true);
int mana = getAvailableManaEstimate(player, true);

// TODO We should make a logical guess here, but for now just uh yknow randomly decide?
// What do I want to play next? Can I still pay for that and have mana left over to help?
Original file line number Diff line number Diff line change
@@ -177,7 +177,6 @@ public int evalManaBase(Game game, Player player, AiDeckStatistics statistics) {
// TODO should these be fixed quantities or should they be linear out of like 1000/(desired - total)?
int value = 0;
// get the colors of mana we can produce and the maximum number of pips
int max_colored = 0;
int max_total = 0;
// this logic taken from ManaCost.getColorShardCounts()
int[] counts = new int[6]; // in WUBRGC order
16 changes: 14 additions & 2 deletions forge-game/src/main/java/forge/game/card/Card.java
Original file line number Diff line number Diff line change
@@ -64,6 +64,8 @@
import java.util.*;
import java.util.Map.Entry;

import static java.lang.Math.max;

/**
* <p>
* Card class.
@@ -1751,7 +1753,7 @@ public final int subtractCounter(final CounterType counterName, final int n, fin

public final int subtractCounter(final CounterType counterName, final int n, final Player remover, final boolean isDamage) {
int oldValue = getCounters(counterName);
int newValue = Math.max(oldValue - n, 0);
int newValue = max(oldValue - n, 0);

final Map<AbilityKey, Object> repParams = AbilityKey.mapFromAffected(this);
repParams.put(AbilityKey.CounterType, counterName);
@@ -3361,6 +3363,16 @@ public final boolean canProduceSameManaTypeWith(final Card c) {
return canProduceColorMana(colors);
}

public final int getMaxManaProduced() {
int max_produced = 0;
for (SpellAbility m: getManaAbilities()) {
m.setActivatingPlayer(getController());
int mana_cost = m.getPayCosts().getTotalMana().getCMC();
max_produced = max(max_produced, m.amountOfManaGenerated(true) - mana_cost);
}
return max_produced;
}

public final void clearFirstSpell() {
currentState.clearFirstSpell();
}
@@ -6184,7 +6196,7 @@ public final int getExcessDamageValue(boolean withDeathtouch) {
if (withDeathtouch && lethal > 0) {
excessCharacteristics.add(1);
} else {
excessCharacteristics.add(Math.max(0, lethal));
excessCharacteristics.add(max(0, lethal));
}
}
if (this.isPlaneswalker()) {

0 comments on commit 4c7a55a

Please sign in to comment.