Skip to content

Commit

Permalink
Merge pull request Card-Forge#3394 from Agetian/ai-banding
Browse files Browse the repository at this point in the history
Basic AI logic for Banding / Bands with Other
  • Loading branch information
kevlahnota authored Jul 5, 2023
2 parents 1ad21f0 + bac3b77 commit bfd9336
Show file tree
Hide file tree
Showing 47 changed files with 179 additions and 83 deletions.
124 changes: 105 additions & 19 deletions forge-ai/src/main/java/forge/ai/AiAttackController.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@
import forge.util.collect.FCollectionView;
import org.apache.commons.lang3.tuple.Pair;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.*;


/**
Expand Down Expand Up @@ -360,35 +357,41 @@ public final static Card getCardCanBlockAnAttacker(final Card c, final List<Card
}

// this checks to make sure that the computer player doesn't lose when the human player attacks
public final List<Card> notNeededAsBlockers(final List<Card> attackers) {
public final List<Card> notNeededAsBlockers(final List<Card> currentAttackers, final List<Card> potentialAttackers) {
//check for time walks
if (ai.getGame().getPhaseHandler().getNextTurn().equals(ai)) {
return attackers;
return potentialAttackers;
}
// no need to block (already holding mana to cast fog next turn)
if (!AiCardMemory.isMemorySetEmpty(ai, AiCardMemory.MemorySet.CHOSEN_FOG_EFFECT)) {
// Don't send the card that'll do the fog effect to attack, it's unsafe!

List<Card> toRemove = Lists.newArrayList();
for (Card c : attackers) {
for (Card c : potentialAttackers) {
if (AiCardMemory.isRememberedCard(ai, c, AiCardMemory.MemorySet.CHOSEN_FOG_EFFECT)) {
toRemove.add(c);
}
}
attackers.removeAll(toRemove);
return attackers;
potentialAttackers.removeAll(toRemove);
return potentialAttackers;
}

if (ai.isCardInPlay("Masako the Humorless")) {
// "Tapped creatures you control can block as though they were untapped."
return potentialAttackers;
}

final CardCollection notNeededAsBlockers = new CardCollection(potentialAttackers);

final List<Card> vigilantes = new ArrayList<>();
for (final Card c : myList) {
if (c.getName().equals("Masako the Humorless")) {
// "Tapped creatures you control can block as though they were untapped."
return attackers;
}
for (final Card c : Iterables.concat(currentAttackers, potentialAttackers)) {
// no need to block if an effect is in play which untaps all creatures
// (pseudo-Vigilance akin to Awakening or Prophet of Kruphix)
if (c.hasKeyword(Keyword.VIGILANCE) || ComputerUtilCard.willUntap(ai, c)) {
vigilantes.add(c);
} else if (currentAttackers.contains(c)) {
// already attacking so can't block
notNeededAsBlockers.add(c);
}
}
// reduce the search space
Expand All @@ -402,10 +405,8 @@ public boolean apply(final Card c) {
}
});

final CardCollection notNeededAsBlockers = new CardCollection(attackers);

// don't hold back creatures that can't block any of the human creatures
final List<Card> blockers = getPossibleBlockers(attackers, opponentsAttackers, true);
final List<Card> blockers = getPossibleBlockers(potentialAttackers, opponentsAttackers, true);

if (!blockers.isEmpty()) {
notNeededAsBlockers.removeAll(blockers);
Expand Down Expand Up @@ -476,12 +477,15 @@ public boolean apply(final Card c) {
// these creatures will be available to block anyway
notNeededAsBlockers.addAll(vigilantes);

// remove those that were only included to ensure a full picture for the baseline
notNeededAsBlockers.removeAll(currentAttackers);

// Increase the total number of blockers needed by 1 if Finest Hour in play
// (human will get an extra first attack with a creature that untaps)
// In addition, if the computer guesses it needs no blockers, make sure
// that it won't be surprised by Exalted
final int humanExaltedBonus = defendingOpponent.countExaltedBonus();
int blockersNeeded = attackers.size() - notNeededAsBlockers.size();
int blockersNeeded = potentialAttackers.size() - notNeededAsBlockers.size();

if (humanExaltedBonus > 0) {
final boolean finestHour = defendingOpponent.isCardInPlay("Finest Hour");
Expand Down Expand Up @@ -511,6 +515,88 @@ public boolean apply(final Card c) {
return notNeededAsBlockers;
}

public void reinforceWithBanding(final Combat combat) {
reinforceWithBanding(combat, null);
}
public void reinforceWithBanding(final Combat combat, final Card test) {
CardCollection attackers = combat.getAttackers();
if (attackers.isEmpty()) {
return;
}

List<String> bandsWithString = Arrays.asList("Bands with Other Legendary Creatures",
"Bands with Other Creatures named Wolves of the Hunt",
"Bands with Other Dinosaurs");

List<Card> bandingCreatures = null;
if (test == null) {
bandingCreatures = CardLists.filter(myList, card -> card.hasKeyword(Keyword.BANDING) || card.hasAnyKeyword(bandsWithString));

// filter out anything that can't legally attack or is already declared as an attacker
bandingCreatures = CardLists.filter(bandingCreatures, card -> !combat.isAttacking(card) && CombatUtil.canAttack(card));

bandingCreatures = notNeededAsBlockers(attackers, bandingCreatures);
} else {
// Test a specific creature for Banding
if (test.hasKeyword(Keyword.BANDING) || test.hasAnyKeyword(bandsWithString)) {
bandingCreatures = new CardCollection(test);
}
}

// respect global attack constraints
GlobalAttackRestrictions restrict = GlobalAttackRestrictions.getGlobalRestrictions(ai, combat.getDefenders());
int attackMax = restrict.getMax();
if (attackMax >= attackers.size()) {
return;
}

if (bandingCreatures != null) {
List<String> evasionKeywords = Arrays.asList("Flying", "Horsemanship", "Shadow", "Plainswalk", "Islandwalk",
"Forestwalk", "Mountainwalk", "Swampwalk");

// TODO: Assign to band with the best attacker for now, but needs better logic.
for (Card c : bandingCreatures) {
Card bestBand;

if (c.getNetPower() <= 0) {
// Don't band a zero power creature if there's already a banding creature in a band
attackers = CardLists.filter(attackers, card -> combat.getBandOfAttacker(card).getAttackers().size() == 1);
}

Card bestAttacker = ComputerUtilCard.getBestCreatureAI(attackers);
if (c.hasKeyword("Bands with Other Legendary Creatures")) {
bestBand = ComputerUtilCard.getBestCreatureAI(CardLists.getType(attackers, "Legendary"));
} else if (c.hasKeyword("Bands with Other Dinosaurs")) {
bestBand = ComputerUtilCard.getBestCreatureAI(CardLists.getType(attackers, "Dinosaur"));
} else if (c.hasKeyword("Bands with Other Creatures named Wolves of the Hunt")) {
bestBand = ComputerUtilCard.getBestCreatureAI(CardLists.filter(attackers, CardPredicates.nameEquals("Wolves of the Hunt")));
} else if (!c.hasAnyKeyword(evasionKeywords) && bestAttacker != null && bestAttacker.hasAnyKeyword(evasionKeywords)) {
bestBand = ComputerUtilCard.getBestCreatureAI(CardLists.filter(attackers, card -> !card.hasAnyKeyword(evasionKeywords)));
} else {
bestBand = bestAttacker;
}

if (c.getNetPower() <= 0) {
attackers = combat.getAttackers(); // restore the unfiltered attackers
}

if (bestBand != null) {
GameEntity defender = combat.getDefenderByAttacker(bestBand);
if (attackMax == -1) {
// check with the local limitations vs. the chosen defender
attackMax = restrict.getDefenderMax().get(defender) == null ? -1 : restrict.getDefenderMax().get(defender);
}

if (attackMax == -1 || attackMax > combat.getAttackers().size()) {
if (CombatUtil.canAttack(c, defender)) {
combat.addAttacker(c, defender, combat.getBandOfAttacker(bestBand));
}
}
}
}
}
}

private boolean doAssault() {
if (ai.isCardInPlay("Beastmaster Ascension") && this.attackers.size() > 1) {
final CardCollectionView beastions = ai.getCardsIn(ZoneType.Battlefield, "Beastmaster Ascension");
Expand Down Expand Up @@ -1199,7 +1285,7 @@ public int compare(Pair<GameEntity, Integer> r1, Pair<GameEntity, Integer> r2) {
if ( LOG_AI_ATTACKS )
System.out.println("Normal attack");

attackersLeft = notNeededAsBlockers(attackersLeft);
attackersLeft = notNeededAsBlockers(combat.getAttackers(), attackersLeft);
attackersLeft = sortAttackers(attackersLeft);

if ( LOG_AI_ATTACKS )
Expand Down
3 changes: 3 additions & 0 deletions forge-ai/src/main/java/forge/ai/AiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,9 @@ public void declareAttackers(Player attacker, Combat combat) {
AiAttackController aiAtk = new AiAttackController(attacker);
lastAttackAggression = aiAtk.declareAttackers(combat);

// Check if we can reinforce with Banding creatures
aiAtk.reinforceWithBanding(combat);

// if invalid: just try an attack declaration that we know to be legal
if (!CombatUtil.validateAttackers(combat)) {
combat.clearAttackers();
Expand Down
33 changes: 33 additions & 0 deletions forge-ai/src/main/java/forge/ai/ComputerUtilCard.java
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,39 @@ && doesCreatureAttackAI(ai, c)) {
}
}

if (keywords.contains("Banding") && !c.hasKeyword(Keyword.BANDING)) {
if (phase.is(PhaseType.COMBAT_BEGIN) && phase.isPlayerTurn(ai) && !ComputerUtilCard.doesCreatureAttackAI(ai, c)) {
// will this card participate in an attacking band?
Card bandingCard = getPumpedCreature(ai, sa, c, toughness, power, keywords);
// TODO: It may be possible to use AiController.getPredictedCombat here, but that makes it difficult to
// use reinforceWithBanding through the attack controller, especially with the extra card parameter in mind
AiAttackController aiAtk = new AiAttackController(ai);
Combat predicted = new Combat(ai);
aiAtk.declareAttackers(predicted);
aiAtk.reinforceWithBanding(predicted, bandingCard);
if (predicted.isAttacking(bandingCard) && predicted.getBandOfAttacker(bandingCard).getAttackers().size() > 1) {
return true;
}
} else if (phase.is(PhaseType.COMBAT_DECLARE_BLOCKERS) && combat != null) {
// does this card block a Trample card or participate in a multi block?
for (Card atk : combat.getAttackers()) {
if (atk.getController().isOpponentOf(ai)) {
CardCollection blockers = combat.getBlockers(atk);
boolean hasBanding = false;
for (Card blocker : blockers) {
if (blocker.hasKeyword(Keyword.BANDING)) {
hasBanding = true;
break;
}
}
if (!hasBanding && ((blockers.contains(c) && blockers.size() > 1) || atk.hasKeyword(Keyword.TRAMPLE))) {
return true;
}
}
}
}
}

final Player opp = ai.getWeakestOpponent();
Card pumped = getPumpedCreature(ai, sa, c, toughness, power, keywords);
List<Card> oppCreatures = opp.getCreaturesInPlay();
Expand Down
47 changes: 30 additions & 17 deletions forge-ai/src/main/java/forge/ai/ComputerUtilCombat.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,17 @@
*/
package forge.ai;

import java.util.List;
import java.util.Map;

import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

import forge.game.Game;
import forge.game.GameEntity;
import forge.game.ability.AbilityKey;
import forge.game.ability.AbilityUtils;
import forge.game.ability.ApiType;
import forge.game.card.Card;
import forge.game.card.CardCollection;
import forge.game.card.CardCollectionView;
import forge.game.card.CardLists;
import forge.game.card.CardPredicates;
import forge.game.card.CardUtil;
import forge.game.card.CounterEnumType;
import forge.game.card.*;
import forge.game.combat.AttackingBand;
import forge.game.combat.Combat;
import forge.game.combat.CombatUtil;
import forge.game.cost.CostPayment;
Expand All @@ -57,6 +48,9 @@
import forge.util.TextUtil;
import forge.util.collect.FCollection;

import java.util.List;
import java.util.Map;


/**
* <p>
Expand Down Expand Up @@ -2023,6 +2017,8 @@ public static boolean canDestroyBlocker(Player ai, Card blocker, Card attacker,
* distributeAIDamage.
* </p>
*
* @param self
* a {@link forge.game.player.Player} object.
* @param attacker
* a {@link forge.game.card.Card} object.
* @param block
Expand All @@ -2031,16 +2027,20 @@ public static boolean canDestroyBlocker(Player ai, Card blocker, Card attacker,
* @param defender
* @param overrideOrder overriding combatant order
*/
public static Map<Card, Integer> distributeAIDamage(final Card attacker, final CardCollectionView block, final CardCollectionView remaining, int dmgCanDeal, GameEntity defender, boolean overrideOrder) {
// TODO: Distribute defensive Damage (AI controls how damage is dealt to own cards) for Banding and Defensive Formation
public static Map<Card, Integer> distributeAIDamage(final Player self, final Card attacker, final CardCollectionView block, final CardCollectionView remaining, int dmgCanDeal, GameEntity defender, boolean overrideOrder) {
Map<Card, Integer> damageMap = Maps.newHashMap();
Combat combat = attacker.getGame().getCombat();

boolean isAttacking = defender != null;

// Check for Banding, Defensive Formation
boolean isAttackingMe = isAttacking && combat.getDefenderPlayerByAttacker(attacker).equals(self);
boolean isBlockingMyBand = attacker.getController().isOpponentOf(self) && AttackingBand.isValidBand(block, true);
final boolean aiDistributesBandingDmg = isAttackingMe || isBlockingMyBand;

final boolean hasTrample = attacker.hasKeyword(Keyword.TRAMPLE);

if (combat != null && remaining != null && hasTrample && attacker.isAttacking()) {
if (combat != null && remaining != null && hasTrample && attacker.isAttacking() && !aiDistributesBandingDmg) {
// if attacker has trample and some of its blockers are also blocking others it's generally a good idea
// to assign those without trample first so we can maximize the damage to the defender
for (final Card c : remaining) {
Expand All @@ -2061,7 +2061,7 @@ public static Map<Card, Integer> distributeAIDamage(final Card attacker, final C
final Card blocker = block.getFirst();
int dmgToBlocker = dmgCanDeal;

if (hasTrample && isAttacking) { // otherwise no entity to deliver damage via trample
if (hasTrample && isAttacking && !aiDistributesBandingDmg) { // otherwise no entity to deliver damage via trample
dmgToBlocker = getEnoughDamageToKill(blocker, dmgCanDeal, attacker, true);

if (dmgCanDeal < dmgToBlocker) {
Expand All @@ -2077,7 +2077,7 @@ public static Map<Card, Integer> distributeAIDamage(final Card attacker, final C
}
damageMap.put(blocker, dmgToBlocker);
} // 1 blocker
else {
else if (!aiDistributesBandingDmg) {
// Does the attacker deal lethal damage to all blockers
//Blocking Order now determined after declare blockers
Card lastBlocker = null;
Expand All @@ -2098,13 +2098,26 @@ public static Map<Card, Integer> distributeAIDamage(final Card attacker, final C
}
} // for

if (dmgCanDeal > 0 ) { // if any damage left undistributed,
if (dmgCanDeal > 0) { // if any damage left undistributed,
if (hasTrample && isAttacking) // if you have trample, deal damage to defending entity
damageMap.put(null, dmgCanDeal);
else if (lastBlocker != null) { // otherwise flush it into last blocker
damageMap.put(lastBlocker, dmgCanDeal + damageMap.get(lastBlocker));
}
}
} else {
// In the event of Banding or Defensive Formation, assign max damage to the blocker who
// can tank all the damage or to the worst blocker to lose as little as possible
for (final Card b : block) {
final int dmgToKill = getEnoughDamageToKill(b, dmgCanDeal, attacker, true);
if (dmgToKill > dmgCanDeal) {
damageMap.put(b, dmgCanDeal);
break;
}
}
if (damageMap.isEmpty()) {
damageMap.put(ComputerUtilCard.getWorstCreatureAI(block), dmgCanDeal);
}
}
return damageMap;
}
Expand Down
2 changes: 1 addition & 1 deletion forge-ai/src/main/java/forge/ai/PlayerControllerAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public List<PaperCard> sideboard(Deck deck, GameType gameType, String message) {

@Override
public Map<Card, Integer> assignCombatDamage(Card attacker, CardCollectionView blockers, CardCollectionView remaining, int damageDealt, GameEntity defender, boolean overrideOrder) {
return ComputerUtilCombat.distributeAIDamage(attacker, blockers, remaining, damageDealt, defender, overrideOrder);
return ComputerUtilCombat.distributeAIDamage(player, attacker, blockers, remaining, damageDealt, defender, overrideOrder);
}

@Override
Expand Down
4 changes: 2 additions & 2 deletions forge-ai/src/main/java/forge/ai/ability/DebuffAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ protected boolean canPlayAI(final Player ai, final SpellAbility sa) {
|| ph.getPhase().isAfter(PhaseType.COMBAT_DECLARE_BLOCKERS)
|| !game.getStack().isEmpty()) {
// Instant-speed pumps should not be cast outside of combat when the
// stack is empty
if (!SpellAbilityAi.isSorcerySpeed(sa, ai)) {
// stack is empty, unless there are specific activation phase requirements
if (!SpellAbilityAi.isSorcerySpeed(sa, ai) && !sa.hasParam("ActivationPhases")) {
return false;
}
}
Expand Down
Loading

0 comments on commit bfd9336

Please sign in to comment.