AI: improved combat support:

* added support to attack battle permanents (#10246);
* fixed that AI was able to attack multiple targets by single creature (#7434);
* added docs;
* added todos with another bugs or possible problems with AI combat;
This commit is contained in:
Oleg Agafonov 2024-01-19 23:37:35 +04:00
parent 6858d43547
commit e4157fefb8
16 changed files with 239 additions and 89 deletions

View file

@ -247,10 +247,10 @@ public class ContinuousEffects implements Serializable {
.collect(Collectors.toList());
}
public Map<RequirementEffect, Set<Ability>> getApplicableRequirementEffects(Permanent permanent, boolean playerRealted, Game game) {
public Map<RequirementEffect, Set<Ability>> getApplicableRequirementEffects(Permanent permanent, boolean playerRelated, Game game) {
Map<RequirementEffect, Set<Ability>> effects = new HashMap<>();
for (RequirementEffect effect : requirementEffects) {
if (playerRealted == effect.isPlayerRelated()) {
if (playerRelated == effect.isPlayerRelated()) {
Set<Ability> abilities = requirementEffects.getAbility(effect.getId());
Set<Ability> applicableAbilities = new HashSet<>();
for (Ability ability : abilities) {

View file

@ -58,6 +58,7 @@ public abstract class RequirementEffect extends ContinuousEffectImpl {
/**
* Defines the defender a attacker has to attack
* TODO: WTF, it bugged, usage code need only players to attack, but effects return controller, source id, other broken data!!!
*
* @param source
* @param game

View file

@ -60,7 +60,7 @@ public class Combat implements Serializable, Copyable<Combat> {
protected List<CombatGroup> groups = new ArrayList<>();
protected Map<UUID, CombatGroup> blockingGroups = new HashMap<>();
// player and planeswalker ids
// all possible defenders (players, planeswalkers or battle)
protected Set<UUID> defenders = new HashSet<>();
// how many creatures attack defending player
protected Map<UUID, Set<UUID>> numberCreaturesDefenderAttackedBy = new HashMap<>();
@ -115,7 +115,7 @@ public class Combat implements Serializable, Copyable<Combat> {
}
/**
* Get all possible defender (players and planeswalkers) That does not mean
* Get all possible defender (players, planeswalkers and battles) That does not mean
* necessarily mean that they are really attacked
*
* @return
@ -447,13 +447,18 @@ public class Combat implements Serializable, Copyable<Combat> {
game.informPlayers(sb.toString());
}
/**
* Force "must attack" creatures to attack
*/
protected void checkAttackRequirements(Player player, Game game) {
//20101001 - 508.1d
for (Permanent creature : player.getAvailableAttackers(game)) {
// find must attack targets
boolean mustAttack = false;
Set<UUID> defendersForcedToAttack = new HashSet<>();
Set<UUID> defendersForcedToAttack = new HashSet<>(); // contains only forced defenders
if (creature.getGoadingPlayers().isEmpty()) {
// check if a creature has to attack
// must attack effects (not goad)
for (Map.Entry<RequirementEffect, Set<Ability>> entry : game.getContinuousEffects().getApplicableRequirementEffects(creature, false, game).entrySet()) {
RequirementEffect effect = entry.getKey();
if (!effect.mustAttack(game)) {
@ -466,6 +471,8 @@ public class Combat implements Serializable, Copyable<Combat> {
if (defenderId != null) {
// creature is not forced to attack players that are no longer in the game
if (game.getPermanentOrLKIBattlefield(defenderId) == null && game.getPlayer(defenderId).hasLost()) {
// TODO: must attack bugged (not goad)? Must use not LKI here or must check controller's lost too?
// TODO: multiple must attack bugged (not goad)? It skip ALL other effects on outdated one of the effects (must continue instead?)
return;
} else if (defenders.contains(defenderId)) {
defendersForcedToAttack.add(defenderId);
@ -475,9 +482,10 @@ public class Combat implements Serializable, Copyable<Combat> {
}
}
} else {
// goad effects
// if creature is goaded then we start with assumption that it needs to attack any player
mustAttack = true;
// Filter out the planeswalkers
// filter only players
defendersForcedToAttack.addAll(defenders.stream()
.map(game::getPlayer)
.filter(Objects::nonNull)
@ -487,9 +495,17 @@ public class Combat implements Serializable, Copyable<Combat> {
if (!mustAttack) {
continue;
}
// check which defenders the forced to attack creature can attack without paying a cost
Set<UUID> defendersCostlessAttackable = new HashSet<>(defenders);
// If a goaded creature can't attack for any reason (such as being tapped or having come under
// that player's control that turn), then it doesn't attack. If there's a cost associated with having
// it attack, its controller isn't forced to pay that cost, so it doesn't have to attack in that case
// either.
// (2020-04-17)
// remove costable targets from require list
Set<UUID> defendersCostlessAttackable = new HashSet<>(defenders); // contains all defenders (forced + own)
for (UUID defenderId : defenders) {
// filter must pay to attack
if (game.getContinuousEffects().checkIfThereArePayCostToAttackBlockEffects(
new DeclareAttackerEvent(defenderId, creature.getId(), creature.getControllerId()), game
)) {
@ -497,6 +513,8 @@ public class Combat implements Serializable, Copyable<Combat> {
defendersForcedToAttack.remove(defenderId);
continue;
}
// filter can't attack
for (Map.Entry<RestrictionEffect, Set<Ability>> entry : game.getContinuousEffects().getApplicableRestrictionEffects(creature, game).entrySet()) {
if (entry
.getValue()
@ -511,17 +529,34 @@ public class Combat implements Serializable, Copyable<Combat> {
break;
}
}
// Remove all the players which have goaded the attacker
// If a goaded creature doesn't meet any of the above exceptions and can attack,
// it must attack a player other than a player who goaded it if able.
// It the creature can't attack any of those players but could otherwise attack,
// it must attack an opposing planeswalker (controlled by any opponent) or a player who goaded it.
// (2020-04-17)
// filter goaded players
defendersForcedToAttack.removeAll(creature.getGoadingPlayers());
// force attack only if a defender can be attacked without paying a cost
// if no free and valid targets then skip forced attack at all
if (defendersCostlessAttackable.isEmpty()) {
continue;
}
// OK, creature can be forced to attack something here
// TODO: bugged, can't attack own planeswalker on restricted opponent? Must store defendersToChooseFrom?
creaturesForcedToAttack.put(creature.getId(), defendersForcedToAttack);
// No need to attack a special defender
// If a creature you control has been goaded by multiple opponents, it must attack one of your opponents
// who hasn't goaded it. If a creature you control has been goaded by each of your opponents,
// you choose which opponent it attacks.
// (2020-04-17)
Set<UUID> defendersToChooseFrom = defendersForcedToAttack.isEmpty() ? defendersCostlessAttackable : defendersForcedToAttack;
if (defendersToChooseFrom.size() == 1) {
// TODO: add game log here about forced to attack?
player.declareAttacker(creature.getId(), defendersToChooseFrom.iterator().next(), game, false);
continue;
}
@ -1271,9 +1306,12 @@ public class Combat implements Serializable, Copyable<Combat> {
}
public void setDefenders(Game game) {
// player + planeswalkers
for (UUID playerId : getAttackablePlayers(game)) {
addDefender(playerId, game);
addDefendersFromPlayer(playerId, game);
}
// battles
for (Permanent permanent : game.getBattlefield().getActivePermanents(filterBattles, attackingPlayerId, game)) {
defenders.add(permanent.getId());
}
@ -1316,22 +1354,31 @@ public class Combat implements Serializable, Copyable<Combat> {
return attackablePlayers;
}
private void addDefender(UUID defenderId, Game game) {
if (!defenders.contains(defenderId)) {
private void addDefendersFromPlayer(UUID playerId, Game game) {
if (!defenders.contains(playerId)) {
// workaround to find max number of attackers, example: Mirri, Weatherlight Duelist
// TODO: must research - is Integer.MIN_VALUE logic works fine on maxAttackers usage
if (maxAttackers < Integer.MAX_VALUE) {
Player defendingPlayer = game.getPlayer(defenderId);
Player defendingPlayer = game.getPlayer(playerId);
if (defendingPlayer != null) {
if (defendingPlayer.getMaxAttackedBy() == Integer.MAX_VALUE) {
maxAttackers = Integer.MAX_VALUE;
} else if (maxAttackers == Integer.MIN_VALUE) {
// first player
maxAttackers = defendingPlayer.getMaxAttackedBy();
} else {
// second+ player
maxAttackers += defendingPlayer.getMaxAttackedBy();
}
}
}
defenders.add(defenderId);
for (Permanent permanent : game.getBattlefield().getAllActivePermanents(StaticFilters.FILTER_PERMANENT_PLANESWALKER, defenderId, game)) {
// player
defenders.add(playerId);
// planeswalkers
for (Permanent permanent : game.getBattlefield().getAllActivePermanents(StaticFilters.FILTER_PERMANENT_PLANESWALKER, playerId, game)) {
defenders.add(permanent.getId());
}
}

View file

@ -31,12 +31,12 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
protected List<UUID> attackerOrder = new ArrayList<>();
protected Map<UUID, UUID> players = new HashMap<>();
protected boolean blocked;
protected UUID defenderId; // planeswalker or player, can be null after remove from combat (e.g. due damage)
protected UUID defenderId; // planeswalker, player, or battle id, can be null after remove from combat (e.g. due damage)
protected UUID defendingPlayerId;
protected boolean defenderIsPermanent;
/**
* @param defenderId the player that controls the defending permanents
* @param defenderId player, planeswalker or battle that defending
* @param defenderIsPermanent is the defender a permanent
* @param defendingPlayerId regular controller of the defending permanents
*/

View file

@ -310,7 +310,15 @@ public interface Permanent extends Card, Controllable {
boolean canBlockAny(Game game);
boolean canBeAttacked(UUID attackerId, UUID playerToAttack, Game game);
/**
* Fast check for attacking possibilities (is it possible to attack permanent/planeswalker/battle)
*
* @param attackerId creature to attack, can be null
* @param defendingPlayerId defending player
* @param game
* @return
*/
boolean canBeAttacked(UUID attackerId, UUID defendingPlayerId, Game game);
/**
* Checks by restriction effects if the permanent can use activated

View file

@ -1435,12 +1435,12 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
}
@Override
public boolean canBeAttacked(UUID attackerId, UUID playerToAttack, Game game) {
public boolean canBeAttacked(UUID attackerId, UUID defendingPlayerId, Game game) {
if (isPlaneswalker(game)) {
return isControlledBy(playerToAttack);
return isControlledBy(defendingPlayerId);
}
if (isBattle(game)) {
return isProtectedBy(playerToAttack);
return isProtectedBy(defendingPlayerId);
}
return false;
}

View file

@ -786,6 +786,7 @@ public interface Player extends MageItem, Copyable<Player> {
void pickCard(List<Card> cards, Deck deck, Draft draft);
// TODO: add result, process it in AI code (if something put creature to attack then it can broke current AI logic)
void declareAttacker(UUID attackerId, UUID defenderId, Game game, boolean allowUndo);
void declareBlocker(UUID defenderId, UUID blockerId, UUID attackerId, Game game);

View file

@ -2710,6 +2710,7 @@ public abstract class PlayerImpl implements Player, Serializable {
@Override
public void declareAttacker(UUID attackerId, UUID defenderId, Game game, boolean allowUndo) {
if (allowUndo) {
// TODO: allowUndo is smells bad, must be researched (what will be restored on allowUndo = false and why there are so diff usage), 2024-01-16
setStoredBookmark(game.bookmarkState()); // makes it possible to UNDO a declared attacker with costs from e.g. Propaganda
}
Permanent attacker = game.getPermanent(attackerId);