AI: improved target amount targeting (part of #13638, #13766):

- refactor: migrated AI's target amount code to shared selection logic;
- ai: fixed game freezes on some use cases;
- tests: added AI's testable dialogs for target amount;
- tests: improved load tests result table, added game cycles stats;
- Dwarven Catapult - fixed game error on usage;
This commit is contained in:
Oleg Agafonov 2025-06-20 18:20:50 +04:00
parent 8e7a7e9fc6
commit f3e18e245f
23 changed files with 502 additions and 390 deletions

View file

@ -400,7 +400,7 @@ public class ComputerPlayer6 extends ComputerPlayer {
if (effect != null
&& stackObject.getControllerId().equals(playerId)) {
Target target = effect.getTarget();
if (!target.isChoiceCompleted(game)) {
if (!target.isChoiceCompleted(getId(), (StackAbility) stackObject, game)) {
for (UUID targetId : target.possibleTargets(stackObject.getControllerId(), stackObject.getStackAbility(), game)) {
Game sim = game.createSimulationForAI();
StackAbility newAbility = (StackAbility) stackObject.copy();
@ -849,10 +849,12 @@ public class ComputerPlayer6 extends ComputerPlayer {
if (targets.isEmpty()) {
return super.chooseTarget(outcome, cards, target, source, game);
}
if (!target.isChoiceCompleted(game)) {
UUID abilityControllerId = target.getAffectedAbilityControllerId(getId());
if (!target.isChoiceCompleted(abilityControllerId, source, game)) {
for (UUID targetId : targets) {
target.addTarget(targetId, source, game);
if (target.isChoiceCompleted(game)) {
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
targets.clear();
return true;
}
@ -867,10 +869,12 @@ public class ComputerPlayer6 extends ComputerPlayer {
if (targets.isEmpty()) {
return super.choose(outcome, cards, target, source, game);
}
if (!target.isChoiceCompleted(game)) {
UUID abilityControllerId = target.getAffectedAbilityControllerId(getId());
if (!target.isChoiceCompleted(abilityControllerId, source, game)) {
for (UUID targetId : targets) {
target.add(targetId, game);
if (target.isChoiceCompleted(game)) {
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
targets.clear();
return true;
}

View file

@ -20,9 +20,7 @@ import mage.cards.repository.CardInfo;
import mage.cards.repository.CardRepository;
import mage.choices.Choice;
import mage.constants.*;
import mage.counters.CounterType;
import mage.filter.FilterPermanent;
import mage.filter.StaticFilters;
import mage.filter.common.FilterCreatureForCombatBlock;
import mage.filter.common.FilterLandCard;
import mage.filter.common.FilterNonlandCard;
@ -158,16 +156,7 @@ public class ComputerPlayer extends PlayerImpl {
return false;
}
// controller hints:
// - target.getTargetController(), this.getId() -- player that must makes choices (must be same with this.getId)
// - target.getAbilityController(), abilityControllerId -- affected player/controller for all actions/filters
// - affected controller can be different from target controller (another player makes choices for controller)
// sometimes a target selection can be made from a player that does not control the ability
UUID abilityControllerId = playerId;
if (target.getTargetController() != null
&& target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(getId());
// nothing to choose, e.g. X=0
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
@ -175,13 +164,11 @@ public class ComputerPlayer extends PlayerImpl {
}
// default logic for any targets
boolean isAddedSomething = false;
PossibleTargetsSelector possibleTargetsSelector = new PossibleTargetsSelector(outcome, target, abilityControllerId, source, game);
possibleTargetsSelector.findNewTargets(fromCards);
// good targets -- choose as much as possible
for (MageItem item : possibleTargetsSelector.getGoodTargets()) {
target.add(item.getId(), game);
isAddedSomething = true;
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
@ -192,9 +179,9 @@ public class ComputerPlayer extends PlayerImpl {
break;
}
target.add(item.getId(), game);
isAddedSomething = true;
}
return isAddedSomething;
return target.isChosen(game) && !target.getTargets().isEmpty();
}
/**
@ -273,147 +260,154 @@ public class ComputerPlayer extends PlayerImpl {
@Override
public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) {
// TODO: make same code for chooseTarget (without filter and target type dependence)
// nothing to choose, e.g. X=0
target.prepareAmount(source, game);
if (target.getAmountRemaining() <= 0) {
return false;
}
UUID sourceId = source != null ? source.getSourceId() : null;
// sometimes a target selection can be made from a player that does not control the ability
UUID abilityControllerId = playerId;
if (target.getTargetController() != null
&& target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
// process multiple opponents by random
List<UUID> opponents = new ArrayList<>(game.getOpponents(getId(), true));
Collections.shuffle(opponents);
List<Permanent> targets;
// ONE KILL PRIORITY: player -> planeswalker -> creature
if (outcome == Outcome.Damage) {
// player kill
for (UUID opponentId : opponents) {
Player opponent = game.getPlayer(opponentId);
if (opponent != null
&& target.canTarget(abilityControllerId, opponentId, source, game)
&& opponent.getLife() <= target.getAmountRemaining()) {
return tryAddTarget(target, opponentId, opponent.getLife(), source, game);
}
}
// permanents kill
for (UUID opponentId : opponents) {
targets = threats(opponentId, source, StaticFilters.FILTER_PERMANENT_CREATURE_OR_PLANESWALKER_A, game, target.getTargets());
// planeswalker kill
for (Permanent permanent : targets) {
if (permanent.isPlaneswalker(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
int loy = permanent.getCounters(game).getCount(CounterType.LOYALTY);
if (loy <= target.getAmountRemaining()) {
return tryAddTarget(target, permanent.getId(), loy, source, game);
}
}
}
// creature kill
for (Permanent permanent : targets) {
if (permanent.isCreature(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
if (permanent.getToughness().getValue() <= target.getAmountRemaining()) {
return tryAddTarget(target, permanent.getId(), permanent.getToughness().getValue(), source, game);
}
}
}
}
}
// NORMAL PRIORITY: planeswalker -> player -> creature
// own permanents will be checked multiple times... that's ok
for (UUID opponentId : opponents) {
if (outcome.isGood()) {
targets = threats(getId(), source, StaticFilters.FILTER_PERMANENT, game, target.getTargets());
} else {
targets = threats(opponentId, source, StaticFilters.FILTER_PERMANENT, game, target.getTargets());
}
// planeswalkers
for (Permanent permanent : targets) {
if (permanent.isPlaneswalker(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
return tryAddTarget(target, permanent.getId(), target.getAmountRemaining(), source, game);
}
}
// players
if (outcome.isGood() && target.canTarget(abilityControllerId, getId(), source, game)) {
return tryAddTarget(target, getId(), target.getAmountRemaining(), source, game);
}
if (!outcome.isGood() && target.canTarget(abilityControllerId, opponentId, source, game)) {
return tryAddTarget(target, opponentId, target.getAmountRemaining(), source, game);
}
// creature
for (Permanent permanent : targets) {
if (permanent.isCreature(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
return tryAddTarget(target, permanent.getId(), target.getAmountRemaining(), source, game);
}
}
}
// BAD PRIORITY, e.g. need bad target on yourself or good target on opponent
// priority: creature (non killable, killable) -> planeswalker -> player
if (!target.isRequired(sourceId, game)) {
if (target.getMaxNumberOfTargets() == 0 && target.getMinNumberOfTargets() == 0) {
return false;
}
for (UUID opponentId : opponents) {
if (!outcome.isGood()) {
// bad on yourself, uses the weakest targets
targets = threats(getId(), source, StaticFilters.FILTER_PERMANENT, game, target.getTargets(), false);
} else {
targets = threats(opponentId, source, StaticFilters.FILTER_PERMANENT, game, target.getTargets(), false);
}
// creatures - non killable (TODO: add extra skill checks like indestructible)
for (Permanent permanent : targets) {
if (permanent.isCreature(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
int safeDamage = Math.min(permanent.getToughness().getValue() - 1, target.getAmountRemaining());
if (safeDamage > 0) {
return tryAddTarget(target, permanent.getId(), safeDamage, source, game);
UUID abilityControllerId = target.getAffectedAbilityControllerId(getId());
// nothing to choose, e.g. X=0
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return false;
}
PossibleTargetsSelector possibleTargetsSelector = new PossibleTargetsSelector(outcome, target, abilityControllerId, source, game);
possibleTargetsSelector.findNewTargets(null);
// nothing to choose, e.g. no valid targets
if (!possibleTargetsSelector.hasAnyTargets()) {
return false;
}
// KILL PRIORITY
if (outcome == Outcome.Damage) {
// opponent first
for (MageItem item : possibleTargetsSelector.getGoodTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId()) || !(item instanceof Player)) {
continue;
}
int leftLife = PossibleTargetsComparator.getLifeForDamage(item, game);
if (leftLife > 0 && leftLife <= target.getAmountRemaining()) {
target.addTarget(item.getId(), leftLife, source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
}
// creatures - all
for (Permanent permanent : targets) {
if (permanent.isCreature(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
return tryAddTarget(target, permanent.getId(), target.getAmountRemaining(), source, game);
// opponent's creatures second
for (MageItem item : possibleTargetsSelector.getGoodTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId()) || (item instanceof Player)) {
continue;
}
int leftLife = PossibleTargetsComparator.getLifeForDamage(item, game);
if (leftLife > 0 && leftLife <= target.getAmountRemaining()) {
target.addTarget(item.getId(), leftLife, source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
}
// planeswalkers
for (Permanent permanent : targets) {
if (permanent.isPlaneswalker(game) && target.canTarget(abilityControllerId, permanent.getId(), source, game)) {
return tryAddTarget(target, permanent.getId(), target.getAmountRemaining(), source, game);
// opponent's any
for (MageItem item : possibleTargetsSelector.getGoodTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId())) {
continue;
}
target.addTarget(item.getId(), target.getAmountRemaining(), source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
// own - non-killable
for (MageItem item : possibleTargetsSelector.getBadTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId())) {
continue;
}
// stop as fast as possible on bad outcome
if (target.isChosen(game)) {
return !target.getTargets().isEmpty();
}
int leftLife = PossibleTargetsComparator.getLifeForDamage(item, game);
if (leftLife > 1) {
target.addTarget(item.getId(), Math.min(leftLife - 1, target.getAmountRemaining()), source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
}
// own - any
for (MageItem item : possibleTargetsSelector.getBadTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId())) {
continue;
}
// stop as fast as possible on bad outcome
if (target.isChosen(game)) {
return !target.getTargets().isEmpty();
}
target.addTarget(item.getId(), target.getAmountRemaining(), source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
return target.isChosen(game);
}
// players
for (UUID opponentId : opponents) {
if (target.canTarget(abilityControllerId, getId(), source, game)) {
// on itself
return tryAddTarget(target, getId(), target.getAmountRemaining(), source, game);
} else if (target.canTarget(abilityControllerId, opponentId, source, game)) {
// on opponent
return tryAddTarget(target, opponentId, target.getAmountRemaining(), source, game);
// non-damage effect like counters - give all to first valid item
for (MageItem item : possibleTargetsSelector.getGoodTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId())) {
continue;
}
target.addTarget(item.getId(), target.getAmountRemaining(), source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
for (MageItem item : possibleTargetsSelector.getBadTargets()) {
if (target.getAmountRemaining() <= 0) {
break;
}
if (target.contains(item.getId())) {
continue;
}
// stop as fast as possible on bad outcome
if (target.isChosen(game)) {
return !target.getTargets().isEmpty();
}
target.addTarget(item.getId(), target.getAmountRemaining(), source, game);
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
// it's ok on no targets available
log.warn("No proper AI target handling or can't find permanents/cards to target: " + target.getClass().getName());
return false;
return target.isChosen(game) && !target.getTargets().isEmpty();
}
@Override
@ -1451,9 +1445,9 @@ public class ComputerPlayer extends PlayerImpl {
}
// sometimes a target selection can be made from a player that does not control the ability
UUID abilityControllerId = playerId;
if (target != null && target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
UUID abilityControllerId = this.getId();
if (target != null) {
abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
}
Card bestCard = null;
@ -1490,9 +1484,9 @@ public class ComputerPlayer extends PlayerImpl {
}
// sometimes a target selection can be made from a player that does not control the ability
UUID abilityControllerId = playerId;
if (target != null && target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
UUID abilityControllerId = this.getId();
if (target != null) {
abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
}
Card worstCard = null;

View file

@ -53,8 +53,7 @@ public class PossibleTargetsComparator {
}
}
private int getScoreFromLife(MageItem item) {
// TODO: replace permanent/card life by battlefield score?
public static int getLifeForDamage(MageItem item, Game game) {
int res = 0;
if (item instanceof Player) {
res = ((Player) item).getLife();
@ -71,12 +70,16 @@ public class PossibleTargetsComparator {
}
res = Math.max(0, card.getToughness().getValue() - damage);
}
// instant
if (res == 0) {
res = card.getManaValue();
}
}
return res;
}
private int getScoreFromLife(MageItem item) {
// TODO: replace permanent/card life by battlefield score?
int res = getLifeForDamage(item, game);
if (res == 0 && item instanceof Card) {
res = ((Card) item).getManaValue();
}
return res;
}
@ -139,13 +142,6 @@ public class PossibleTargetsComparator {
.thenComparing(BY_ID);
public final Comparator<MageItem> ANY_MOST_VALUABLE_LAST = ANY_MOST_VALUABLE_FIRST.reversed();
/**
* Default sorting for good effects - put own and biggest items to the top
*/
public final Comparator<MageItem> MY_MOST_VALUABLE_FIRST = BY_ME
.thenComparing(ANY_MOST_VALUABLE_FIRST);
public final Comparator<MageItem> MY_MOST_VALUABLE_LAST = MY_MOST_VALUABLE_FIRST.reversed();
/**
* Sorting for discard effects - put the biggest unplayable at the top, lands at the end anyway
*/

View file

@ -85,13 +85,13 @@ public class PossibleTargetsSelector {
private void sortByMostValuableTargets() {
if (isGoodEffect()) {
// for good effect must choose the biggest objects
this.me.sort(comparators.MY_MOST_VALUABLE_FIRST);
this.opponents.sort(comparators.MY_MOST_VALUABLE_LAST);
this.me.sort(comparators.ANY_MOST_VALUABLE_FIRST);
this.opponents.sort(comparators.ANY_MOST_VALUABLE_LAST);
this.any.sort(comparators.ANY_MOST_VALUABLE_FIRST);
} else {
// for bad effect must choose the smallest objects
this.me.sort(comparators.MY_MOST_VALUABLE_LAST);
this.opponents.sort(comparators.MY_MOST_VALUABLE_FIRST);
this.me.sort(comparators.ANY_MOST_VALUABLE_LAST);
this.opponents.sort(comparators.ANY_MOST_VALUABLE_FIRST);
this.any.sort(comparators.ANY_MOST_VALUABLE_LAST);
}
}
@ -181,4 +181,8 @@ public class PossibleTargetsSelector {
}
return false;
}
boolean hasAnyTargets() {
return !this.any.isEmpty();
}
}

View file

@ -19,10 +19,10 @@ public final class MagicAbility {
put(DoubleStrikeAbility.getInstance().getRule(), 100);
put(new ExaltedAbility().getRule(), 10);
put(FirstStrikeAbility.getInstance().getRule(), 50);
put(FlashAbility.getInstance().getRule(), 0);
put(FlashAbility.getInstance().getRule(), 20);
put(FlyingAbility.getInstance().getRule(), 50);
put(new ForestwalkAbility().getRule(), 10);
put(HasteAbility.getInstance().getRule(), 0);
put(HasteAbility.getInstance().getRule(), 20);
put(IndestructibleAbility.getInstance().getRule(), 150);
put(InfectAbility.getInstance().getRule(), 60);
put(IntimidateAbility.getInstance().getRule(), 50);
@ -47,7 +47,7 @@ public final class MagicAbility {
if (!scores.containsKey(ability.getRule())) {
//System.err.println("Couldn't find ability score: " + ability.getClass().getSimpleName() + " - " + ability.toString());
//TODO: add handling protection from ..., levelup, kicker, etc. abilities
return 0;
return 2; // more abilities - more score in any use cases
}
return scores.get(ability.getRule());
}

View file

@ -288,9 +288,15 @@ public final class SimulatedPlayerMCTS extends MCTSPlayer {
@Override
public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) {
// nothing to choose
target.prepareAmount(source, game);
if (target.getAmountRemaining() <= 0) {
return false;
}
if (target.getMaxNumberOfTargets() == 0 && target.getMinNumberOfTargets() == 0) {
return false;
}
Set<UUID> possibleTargets = target.possibleTargets(playerId, source, game);
if (possibleTargets.isEmpty()) {

View file

@ -690,12 +690,8 @@ public class HumanPlayer extends PlayerImpl {
return false;
}
// choose one or multiple permanents
UUID abilityControllerId = playerId;
if (target.getTargetController() != null
&& target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
// choose one or multiple targets
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
if (options == null) {
options = new HashMap<>();
}
@ -782,11 +778,7 @@ public class HumanPlayer extends PlayerImpl {
}
// choose one or multiple targets
UUID abilityControllerId = playerId;
if (target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
Map<String, Serializable> options = new HashMap<>();
while (canRespond()) {
@ -869,13 +861,7 @@ public class HumanPlayer extends PlayerImpl {
return false;
}
UUID abilityControllerId;
if (target.getTargetController() != null
&& target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
} else {
abilityControllerId = playerId;
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
while (canRespond()) {
@ -966,13 +952,7 @@ public class HumanPlayer extends PlayerImpl {
return false;
}
UUID abilityControllerId;
if (target.getTargetController() != null
&& target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
} else {
abilityControllerId = playerId;
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
while (canRespond()) {
boolean required = target.isRequiredExplicitlySet() ? target.isRequired() : target.isRequired(source);
@ -1042,18 +1022,20 @@ public class HumanPlayer extends PlayerImpl {
return false;
}
// nothing to choose
target.prepareAmount(source, game);
if (target.getAmountRemaining() <= 0) {
return false;
}
if (target.getMaxNumberOfTargets() == 0 && target.getMinNumberOfTargets() == 0) {
return false;
}
if (source == null) {
return false;
}
UUID abilityControllerId = playerId;
if (target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
int amountTotal = target.getAmountTotal(game, source);
if (amountTotal == 0) {