other: reworked target selection: (#13638)

- WIP: AI and multi targets, human and X=0 use cases, human and impossible targets use cases;
- improved stability and shared logic (related to #13606, #11134, #11666, continue from a53eb66b58, close #13617, close #13613);
- improved test logs and debug info to show more target info on errors;
- improved test framework to support multiple addTarget calls;
- improved test framework to find bad commands order for targets (related to #11666);
- fixed game freezes on auto-choice usages with disconnected or under control players (related to #11285);
- gui, game: fixed that player doesn't mark avatar as selected/green in "up to" targeting;
- gui, game: fixed small font in some popup messages on big screens (related to #969);
- gui, game: added min targets info for target selection dialog;
- for devs: added new cheat option to call and test any game dialog (define own dialogs, targets, etc in HumanDialogsTester);
- for devs: now tests require complete an any or up to target selection by addTarget + TestPlayer.TARGET_SKIP or setChoice + TestPlayer.CHOICE_SKIP (if not all max/possible targets used);
- for devs: added detail targets info for activate/trigger/cast, can be useful to debug unit tests, auto-choose or AI (see DebugUtil.GAME_SHOW_CHOOSE_TARGET_LOGS)
This commit is contained in:
Oleg Agafonov 2025-05-16 13:55:54 +04:00 committed by GitHub
parent 80d62727e1
commit 133e4fe425
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 2737 additions and 743 deletions

View file

@ -41,7 +41,6 @@ import mage.players.net.UserData;
import mage.target.Target;
import mage.target.TargetAmount;
import mage.target.TargetCard;
import mage.target.TargetPermanent;
import mage.target.common.TargetAttackingCreature;
import mage.target.common.TargetDefender;
import mage.target.targetpointer.TargetPointer;
@ -148,7 +147,7 @@ public class HumanPlayer extends PlayerImpl {
}
/**
* Make fake player from any other
* Make fake player from any other
*/
public HumanPlayer(final PlayerImpl sourcePlayer, final PlayerResponse sourceResponse) {
super(sourcePlayer);
@ -387,9 +386,10 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean chooseMulligan(Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
// TODO: rework to use existing chooseUse dialog, but do not remove chooseMulligan (AI must use special logic inside it)
while (canRespond()) {
int nextHandSize = game.mulliganDownTo(playerId);
String cardsCountInfo = nextHandSize + (nextHandSize == 1 ? " card" : " cards");
@ -427,7 +427,11 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean chooseUse(Outcome outcome, String message, String secondMessage, String trueText, String falseText, Ability source, Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
if (message == null) {
throw new IllegalArgumentException("Wrong code usage: main message is null");
}
MessageToClient messageToClient = new MessageToClient(message, secondMessage);
@ -620,7 +624,7 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean choose(Outcome outcome, Choice choice, Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
if (choice.isKeyChoice() && choice.getKeyChoices().isEmpty()) {
@ -683,7 +687,7 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean choose(Outcome outcome, Target target, Ability source, Game game, Map<String, Serializable> options) {
if (!canCallFeedback(game)) {
return true;
return false;
}
// choose one or multiple permanents
@ -696,98 +700,85 @@ public class HumanPlayer extends PlayerImpl {
options = new HashMap<>();
}
// stop on completed, e.g. X=0
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return false;
}
while (canRespond()) {
Set<UUID> possibleTargetIds = target.possibleTargets(abilityControllerId, source, game);
if (possibleTargetIds == null || possibleTargetIds.isEmpty()) {
return target.getTargets().size() >= target.getMinNumberOfTargets();
}
boolean required = target.isRequired(source != null ? source.getSourceId() : null, game);
// enable done button after min targets selected
if (target.getTargets().size() >= target.getMinNumberOfTargets()) {
required = false;
}
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game);
// stop on impossible selection
if (required && !target.canChoose(abilityControllerId, source, game)) {
break;
}
// responseId is null if a choice couldn't be automatically made
if (responseId == null) {
List<UUID> chosenTargets = target.getTargets();
options.put("chosenTargets", (Serializable) chosenTargets);
// stop on nothing to choose
Set<UUID> possibleTargets = target.possibleTargets(abilityControllerId, source, game);
if (required && possibleTargets.isEmpty()) {
break;
}
// MAKE A CHOICE
UUID autoChosenId = target.tryToAutoChoose(abilityControllerId, source, game);
if (autoChosenId != null && !target.contains(autoChosenId)) {
// auto-choose
target.add(autoChosenId, game);
// continue to next target (example: auto-choose must fill min/max = 2 from 2 possible cards)
} else {
// manual choose
options.put("chosenTargets", (Serializable) target.getTargets());
prepareForResponse(game);
if (!isExecutingMacro()) {
game.fireSelectTargetEvent(getId(), new MessageToClient(target.getMessage(game), getRelatedObjectName(source, game)), possibleTargetIds, required, getOptions(target, options));
game.fireSelectTargetEvent(getId(), new MessageToClient(target.getMessage(game), getRelatedObjectName(source, game)), possibleTargets, required, getOptions(target, options));
}
waitForResponse(game);
responseId = getFixedResponseUUID(game);
}
UUID responseId = getFixedResponseUUID(game);
if (responseId != null) {
// selected some target
if (responseId != null) {
// selected something
// remove selected
if (target.getTargets().contains(responseId)) {
target.remove(responseId);
continue;
}
// remove selected
if (target.contains(responseId)) {
target.remove(responseId);
continue;
}
if (!possibleTargetIds.contains(responseId)) {
continue;
}
if (target instanceof TargetPermanent) {
if (((TargetPermanent) target).canTarget(abilityControllerId, responseId, source, game, false)) {
if (possibleTargets.contains(responseId) && target.canTarget(getId(), responseId, source, game)) {
target.add(responseId, game);
if (target.doneChoosing(game)) {
return true;
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
break;
}
}
} else {
MageObject object = game.getObject(source);
if (object instanceof Ability) {
if (target.canTarget(responseId, (Ability) object, game)) {
if (target.getTargets().contains(responseId)) { // if already included remove it with
target.remove(responseId);
} else {
target.addTarget(responseId, (Ability) object, game);
if (target.doneChoosing(game)) {
return true;
}
}
}
} else if (target.canTarget(responseId, game)) {
if (target.getTargets().contains(responseId)) { // if already included remove it with
target.remove(responseId);
} else {
target.addTarget(responseId, null, game);
if (target.doneChoosing(game)) {
return true;
}
// stop on done/cancel button press
if (target.isChosen(game)) {
break;
} else {
if (!required) {
// can stop at any moment
break;
}
}
}
} else {
// send other command like cancel or done (??sends other commands like concede??)
// auto-complete on all selected
if (target.getTargets().size() >= target.getMinNumberOfTargets()) {
return true;
}
// cancel/done button
if (!required) {
return false;
}
// continue to next target
}
}
return false;
return target.isChosen(game) && target.getTargets().size() > 0;
}
@Override
public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
// choose one or multiple targets
@ -799,57 +790,60 @@ public class HumanPlayer extends PlayerImpl {
Map<String, Serializable> options = new HashMap<>();
while (canRespond()) {
Set<UUID> possibleTargetIds = target.possibleTargets(abilityControllerId, source, game);
Set<UUID> possibleTargets = target.possibleTargets(abilityControllerId, source, game);
boolean required = target.isRequired(source != null ? source.getSourceId() : null, game);
if (possibleTargetIds.isEmpty()
if (possibleTargets.isEmpty()
|| target.getTargets().size() >= target.getMinNumberOfTargets()) {
required = false;
}
// auto-choose
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game);
// responseId is null if a choice couldn't be automatically made
// manual choice
if (responseId == null) {
List<UUID> chosenTargets = target.getTargets();
options.put("chosenTargets", (Serializable) chosenTargets);
options.put("chosenTargets", (Serializable) target.getTargets());
prepareForResponse(game);
if (!isExecutingMacro()) {
game.fireSelectTargetEvent(getId(), new MessageToClient(target.getMessage(game), getRelatedObjectName(source, game)),
possibleTargetIds, required, getOptions(target, options));
possibleTargets, required, getOptions(target, options));
}
waitForResponse(game);
responseId = getFixedResponseUUID(game);
}
if (responseId != null) {
// remove selected
if (target.getTargets().contains(responseId)) {
// remove old target
if (target.contains(responseId)) {
target.remove(responseId);
continue;
}
if (possibleTargetIds.contains(responseId)) {
// add new target
if (possibleTargets.contains(responseId)) {
if (target.canTarget(abilityControllerId, responseId, source, game)) {
target.addTarget(responseId, source, game);
if (target.doneChoosing(game)) {
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
}
} else {
if (target.getTargets().size() >= target.getMinNumberOfTargets()) {
return true;
}
if (!required) {
// done or cancel button pressed
if (target.isChosen(game)) {
// try to finish
return false;
} else {
if (!required) {
// can stop at any moment
return false;
}
}
}
}
return false;
return target.isChosen(game) && target.getTargets().size() > 0;
}
private Map<String, Serializable> getOptions(Target target, Map<String, Serializable> options) {
@ -867,10 +861,10 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean choose(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
// choose one or multiple cards
// ignore bad state
if (cards == null || cards.isEmpty()) {
return false;
}
@ -884,6 +878,14 @@ public class HumanPlayer extends PlayerImpl {
}
while (canRespond()) {
List<UUID> possibleTargets = new ArrayList<>();
for (UUID cardId : cards) {
if (target.canTarget(abilityControllerId, cardId, source, cards, game)) {
possibleTargets.add(cardId);
}
}
boolean required = target.isRequired(source != null ? source.getSourceId() : null, game);
int count = cards.count(target.getFilter(), abilityControllerId, source, game);
if (count == 0
@ -891,23 +893,22 @@ public class HumanPlayer extends PlayerImpl {
required = false;
}
List<UUID> chosenTargets = target.getTargets();
List<UUID> possibleTargets = new ArrayList<>();
for (UUID cardId : cards) {
if (target.canTarget(abilityControllerId, cardId, source, cards, game)) {
possibleTargets.add(cardId);
}
}
// if nothing to choose then show dialog (user must see non selectable items and click on any of them)
// TODO: need research - is it used?
if (required && possibleTargets.isEmpty()) {
required = false;
}
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game, possibleTargets);
if (responseId == null) {
// MAKE A CHOICE
UUID autoChosenId = target.tryToAutoChoose(abilityControllerId, source, game, possibleTargets);
if (autoChosenId != null && !target.contains(autoChosenId)) {
// auto-choose
target.add(autoChosenId, game);
// continue to next target (example: auto-choose must fill min/max = 2 from 2 possible cards)
} else {
// manual choose
Map<String, Serializable> options = getOptions(target, null);
options.put("chosenTargets", (Serializable) chosenTargets);
options.put("chosenTargets", (Serializable) target.getTargets());
if (!possibleTargets.isEmpty()) {
options.put("possibleTargets", (Serializable) possibleTargets);
}
@ -918,27 +919,36 @@ public class HumanPlayer extends PlayerImpl {
}
waitForResponse(game);
responseId = getFixedResponseUUID(game);
}
UUID responseId = getFixedResponseUUID(game);
if (responseId != null) {
if (target.getTargets().contains(responseId)) { // if already included remove it with
target.remove(responseId);
} else {
if (target.canTarget(abilityControllerId, responseId, source, cards, game)) {
if (responseId != null) {
// selected something
// remove selected
if (target.contains(responseId)) {
target.remove(responseId);
continue;
}
if (possibleTargets.contains(responseId) && target.canTarget(getId(), responseId, source, cards, game)) {
target.add(responseId, game);
if (target.doneChoosing(game)) {
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
} else {
// done or cancel button pressed
if (target.isChosen(game)) {
// try to finish
return false;
} else {
if (!required) {
// can stop at any moment
return false;
}
}
}
} else {
if (target.getTargets().size() >= target.getMinNumberOfTargets()) {
return true;
}
if (!required) {
return false;
}
// continue to next target
}
}
@ -949,7 +959,7 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
if (cards == null || cards.isEmpty()) {
@ -978,17 +988,16 @@ public class HumanPlayer extends PlayerImpl {
possibleTargets.add(cardId);
}
}
// if nothing to choose then show dialog (user must see non selectable items and click on any of them)
if (required && possibleTargets.isEmpty()) {
// if nothing to choose then show dialog (user must see non-selectable items and click on any of them)
if (possibleTargets.isEmpty()) {
required = false;
}
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game, possibleTargets);
if (responseId == null) {
List<UUID> chosenTargets = target.getTargets();
Map<String, Serializable> options = getOptions(target, null);
options.put("chosenTargets", (Serializable) chosenTargets);
options.put("chosenTargets", (Serializable) target.getTargets());
if (!possibleTargets.isEmpty()) {
options.put("possibleTargets", (Serializable) possibleTargets);
@ -1004,11 +1013,11 @@ public class HumanPlayer extends PlayerImpl {
}
if (responseId != null) {
if (target.getTargets().contains(responseId)) { // if already included remove it
if (target.contains(responseId)) { // if already included remove it
target.remove(responseId);
} else if (target.canTarget(abilityControllerId, responseId, source, cards, game)) {
target.addTarget(responseId, source, game);
if (target.doneChoosing(game)) {
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
return true;
}
}
@ -1030,7 +1039,7 @@ public class HumanPlayer extends PlayerImpl {
// choose amount
// human can choose or un-choose MULTIPLE targets at once
if (!canCallFeedback(game)) {
return true;
return false;
}
if (source == null) {
@ -1057,6 +1066,7 @@ public class HumanPlayer extends PlayerImpl {
// 2. Distribute amount between selected targets
// 1. Select targets
// TODO: rework to use existing chooseTarget instead custom select?
while (canRespond()) {
Set<UUID> possibleTargetIds = target.possibleTargets(abilityControllerId, source, game);
boolean required = target.isRequired(source.getSourceId(), game);
@ -1069,7 +1079,6 @@ public class HumanPlayer extends PlayerImpl {
// responseId is null if a choice couldn't be automatically made
if (responseId == null) {
List<UUID> chosenTargets = target.getTargets();
List<UUID> possibleTargets = new ArrayList<>();
for (UUID targetId : possibleTargetIds) {
if (target.canTarget(abilityControllerId, targetId, source, game)) {
@ -1083,7 +1092,7 @@ public class HumanPlayer extends PlayerImpl {
// selected
Map<String, Serializable> options = getOptions(target, null);
options.put("chosenTargets", (Serializable) chosenTargets);
options.put("chosenTargets", (Serializable) target.getTargets());
if (!possibleTargets.isEmpty()) {
options.put("possibleTargets", (Serializable) possibleTargets);
}
@ -1185,17 +1194,8 @@ public class HumanPlayer extends PlayerImpl {
// TODO: change pass and other states like passedUntilStackResolved for controlling player, not for "this"
// TODO: check and change all "this" to controling player calls, many bugs with hand, mana, skips - https://github.com/magefree/mage/issues/2088
// TODO: use controlling player in all choose dialogs (and canRespond too, what's with take control of player AI?!)
UserData controllingUserData = this.userData;
UserData controllingUserData = this.getControllingPlayersUserData(game);
if (canRespond()) {
if (!isGameUnderControl()) {
Player player = game.getPlayer(getTurnControlledBy());
if (player instanceof HumanPlayer) {
controllingUserData = player.getUserData();
} else {
// TODO: add computer opponent here?!
}
}
// TODO: check that all skips and stops used from real controlling player
// like holdingPriority (is it a bug here?)
if (getJustActivatedType() != null && !holdingPriority) {
@ -1489,7 +1489,7 @@ public class HumanPlayer extends PlayerImpl {
@Override
public TriggeredAbility chooseTriggeredAbility(java.util.List<TriggeredAbility> abilities, Game game) {
// choose triggered abilitity from list
// choose triggered ability from list
if (!canCallFeedback(game)) {
return abilities.isEmpty() ? null : abilities.get(0);
}
@ -1620,7 +1620,7 @@ public class HumanPlayer extends PlayerImpl {
protected boolean playManaHandling(Ability abilityToCast, ManaCost unpaid, String promptText, Game game) {
// choose mana to pay (from permanents or from pool)
if (!canCallFeedback(game)) {
return true;
return false;
}
// TODO: make canRespond cycle?
@ -2624,7 +2624,7 @@ public class HumanPlayer extends PlayerImpl {
@Override
public boolean choosePile(Outcome outcome, String message, java.util.List<? extends Card> pile1, java.util.List<? extends Card> pile2, Game game) {
if (!canCallFeedback(game)) {
return true;
return false;
}
while (canRespond()) {