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

@ -1226,10 +1226,7 @@ public abstract class AbilityImpl implements Ability {
for (Mode mode : modes.values()) {
boolean validTargets = true;
for (Target target : mode.getTargets()) {
UUID abilityControllerId = controllerId;
if (target.getTargetController() != null) {
abilityControllerId = target.getTargetController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(controllerId);
if (!target.canChoose(abilityControllerId, ability, game)) {
validTargets = false;
break;

View file

@ -26,11 +26,9 @@ public interface Target extends Copyable<Target>, Serializable {
* Warning, for "up to" targets it will return true all the time, so make sure your dialog
* use do-while logic and call "choose" one time min or use isChoiceCompleted
*/
@Deprecated // TODO: replace with UUID abilityControllerId, Ability source, Game game
boolean isChosen(Game game);
@Deprecated // TODO: replace usage in cards by full version from choose methods
boolean isChoiceCompleted(Game game);
boolean isChoiceCompleted(UUID abilityControllerId, Ability source, Game game);
void clearChosen();
@ -189,14 +187,18 @@ public interface Target extends Copyable<Target>, Serializable {
Target copy();
// some targets are chosen from players that are not the controller of the ability (e.g. Pandemonium)
// TODO: research usage of setTargetController and setAbilityController - target adjusters must set it both, example: Necrotic Plague
void setTargetController(UUID playerId);
UUID getTargetController();
// TODO: research usage of setTargetController and setAbilityController - target adjusters must set it both, example: Necrotic Plague
void setAbilityController(UUID playerId);
UUID getAbilityController();
UUID getAffectedAbilityControllerId(UUID choosingPlayerId);
Player getTargetController(Game game, UUID playerId);
int getTargetTag();

View file

@ -16,13 +16,15 @@ import java.util.*;
import java.util.stream.Collectors;
/**
* Distribute value between targets list (damage, counters, etc)
*
* @author BetaSteward_at_googlemail.com
*/
public abstract class TargetAmount extends TargetImpl {
boolean amountWasSet = false;
DynamicValue amount;
int remainingAmount;
int remainingAmount; // before any change to it - make sure you call prepareAmount
protected TargetAmount(DynamicValue amount, int minNumberOfTargets, int maxNumberOfTargets) {
this.amount = amount;
@ -46,15 +48,42 @@ public abstract class TargetAmount extends TargetImpl {
@Override
public boolean isChosen(Game game) {
return isChoiceCompleted(game);
if (!super.isChosen(game)) {
return false;
}
// selection not started
if (!amountWasSet) {
return false;
}
// distribution
if (getMinNumberOfTargets() == 0 && this.targets.isEmpty()) {
// allow 0 distribution, e.g. for "up to" targets like Vivien, Arkbow Ranger
return true;
} else {
// need full distribution
return remainingAmount == 0;
}
}
@Override
public boolean isChoiceCompleted(Game game) {
return amountWasSet
&& (remainingAmount == 0
|| (getMinNumberOfTargets() < getMaxNumberOfTargets()
&& getTargets().size() >= getMinNumberOfTargets()));
public boolean isChoiceCompleted(UUID abilityControllerId, Ability source, Game game) {
// make sure target request called one time minimum (for "up to" targets)
// choice is selected after any addTarget call (by test, AI or human players)
if (!isChoiceSelected()) {
return false;
}
// make sure selected targets are valid
if (!isChosen(game)) {
return false;
}
// TODO: need auto-choose here? See super
// all other use cases are fine
return true;
}
@Override
@ -68,9 +97,14 @@ public abstract class TargetAmount extends TargetImpl {
this.amount = amount;
}
public void setAmount(Ability source, Game game) {
remainingAmount = amount.calculate(game, source, null);
amountWasSet = true;
/**
* Prepare new targets for choosing
*/
public void prepareAmount(Ability source, Game game) {
if (!amountWasSet) {
remainingAmount = amount.calculate(game, source, null);
amountWasSet = true;
}
}
public DynamicValue getAmount() {
@ -83,12 +117,11 @@ public abstract class TargetAmount extends TargetImpl {
@Override
public void addTarget(UUID id, int amount, Ability source, Game game, boolean skipEvent) {
if (!amountWasSet) {
setAmount(source, game);
}
prepareAmount(source, game);
if (amount <= remainingAmount) {
super.addTarget(id, amount, source, game, skipEvent);
remainingAmount -= amount;
super.addTarget(id, amount, source, game, skipEvent);
}
}
@ -100,37 +133,70 @@ public abstract class TargetAmount extends TargetImpl {
}
@Override
public boolean choose(Outcome outcome, UUID playerId, UUID sourceId, Ability source, Game game) {
throw new IllegalArgumentException("Wrong code usage. TargetAmount must be called by player.chooseTarget, not player.choose");
}
@Override
@Deprecated // TODO: replace by player.chooseTargetAmount call
public boolean chooseTarget(Outcome outcome, UUID playerId, Ability source, Game game) {
Player player = game.getPlayer(playerId);
if (player == null) {
Player targetController = getTargetController(game, playerId);
if (targetController == null) {
return false;
}
if (!amountWasSet) {
setAmount(source, game);
}
prepareAmount(source, game);
while (remainingAmount > 0) {
chosen = false;
if (!player.canRespond()) {
chosen = isChosen(game);
chosen = false;
do {
int prevTargetsCount = this.getTargets().size();
// stop by disconnect
if (!targetController.canRespond()) {
break;
}
if (!getTargetController(game, playerId).chooseTargetAmount(outcome, this, source, game)) {
chosen = isChosen(game);
break;
// MAKE A CHOICE
if (isRandom()) {
// random choice
throw new IllegalArgumentException("Wrong code usage. TargetAmount do not support random choices");
} else {
// player's choice
// TargetAmount do not support auto-choice
// manual
// stop by cancel/done
if (!targetController.chooseTargetAmount(outcome, this, source, game)) {
break;
}
// continue to next target
}
chosen = isChosen(game);
}
return isChosen(game);
// stop by full complete
if (isChoiceCompleted(targetController.getId(), source, game)) {
break;
}
// stop by nothing to choose (actual for human and done button?)
if (prevTargetsCount == this.getTargets().size()) {
break;
}
// can select next target
} while (true);
chosen = isChosen(game);
return chosen && !this.getTargets().isEmpty();
}
@Override
final public List<? extends TargetAmount> getTargetOptions(Ability source, Game game) {
if (!amountWasSet) {
setAmount(source, game);
}
prepareAmount(source, game);
List<TargetAmount> options = new ArrayList<>();
Set<UUID> possibleTargets = possibleTargets(source.getControllerId(), source, game);
@ -370,9 +436,8 @@ public abstract class TargetAmount extends TargetImpl {
}
public void setTargetAmount(UUID targetId, int amount, Ability source, Game game) {
if (!amountWasSet) {
setAmount(source, game);
}
prepareAmount(source, game);
remainingAmount -= (amount - this.getTargetAmount(targetId));
this.setTargetAmount(targetId, amount, game);
}
@ -396,4 +461,13 @@ public abstract class TargetAmount extends TargetImpl {
// Each of these targets must receive at least one of whatever is being divided.
return amount instanceof StaticValue && max == ((StaticValue) amount).getValue();
}
@Override
public String toString() {
if (amountWasSet) {
return super.toString() + String.format(" (remain amount %d of %s)", this.remainingAmount, this.amount.toString());
} else {
return super.toString() + String.format(" (remain not prepared, %s)", this.amount.toString());
}
}
}

View file

@ -260,11 +260,6 @@ public abstract class TargetImpl implements Target {
return chosen || (targets.size() >= getMinNumberOfTargets() && targets.size() <= getMaxNumberOfTargets());
}
@Override
public boolean isChoiceCompleted(Game game) {
return isChoiceCompleted(null, null, game);
}
@Override
public boolean isChoiceCompleted(UUID abilityControllerId, Ability source, Game game) {
// make sure target request called one time minimum (for "up to" targets)
@ -406,10 +401,7 @@ public abstract class TargetImpl implements Target {
return false;
}
UUID abilityControllerId = playerId;
if (this.getTargetController() != null && this.getAbilityController() != null) {
abilityControllerId = this.getAbilityController();
}
UUID abilityControllerId = this.getAffectedAbilityControllerId(playerId);
chosen = false;
do {
@ -444,7 +436,7 @@ public abstract class TargetImpl implements Target {
} while (true);
chosen = isChosen(game);
return this.getTargets().size() > 0;
return chosen && !this.getTargets().isEmpty();
}
@Override
@ -454,10 +446,7 @@ public abstract class TargetImpl implements Target {
return false;
}
UUID abilityControllerId = playerId;
if (this.getTargetController() != null && this.getAbilityController() != null) {
abilityControllerId = this.getAbilityController();
}
UUID abilityControllerId = this.getAffectedAbilityControllerId(playerId);
List<UUID> randomPossibleTargets = new ArrayList<>(possibleTargets(playerId, source, game));
@ -527,7 +516,7 @@ public abstract class TargetImpl implements Target {
} while (true);
chosen = isChosen(game);
return this.getTargets().size() > 0;
return chosen && !this.getTargets().isEmpty();
}
@Override
@ -726,6 +715,20 @@ public abstract class TargetImpl implements Target {
return abilityController;
}
@Override
public UUID getAffectedAbilityControllerId(UUID choosingPlayerId) {
// controller hints:
// - target.getTargetController(), this.getId(), choosingPlayerId -- 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 = choosingPlayerId;
if (this.getAbilityController() != null) {
abilityControllerId = this.getAbilityController();
}
return abilityControllerId;
}
@Override
public Player getTargetController(Game game, UUID playerId) {
if (getTargetController() != null) {

View file

@ -63,8 +63,8 @@ public class Targets extends ArrayList<Target> implements Copyable<Targets> {
return unchosenIndex < res.size() ? res.get(unchosenIndex) : null;
}
public boolean isChoiceCompleted(Game game) {
return stream().allMatch(t -> t.isChoiceCompleted(game));
public boolean isChoiceCompleted(UUID abilityControllerId, Ability source, Game game) {
return stream().allMatch(t -> t.isChoiceCompleted(abilityControllerId, source, game));
}
public void clearChosen() {
@ -101,9 +101,7 @@ public class Targets extends ArrayList<Target> implements Copyable<Targets> {
// stop on cancel/done
if (!target.choose(outcome, playerId, sourceId, source, game)) {
if (!target.isChosen(game)) {
break;
}
break;
}
// target done, can take next one

View file

@ -76,10 +76,7 @@ public class TargetCardInLibrary extends TargetCard {
Cards cardsId = new CardsImpl();
cards.forEach(cardsId::add);
UUID abilityControllerId = playerId;
if (this.getTargetController() != null && this.getAbilityController() != null) {
abilityControllerId = this.getAbilityController();
}
UUID abilityControllerId = this.getAffectedAbilityControllerId(playerId);
chosen = false;
do {