forked from External/mage
- 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;
2918 lines
121 KiB
Java
2918 lines
121 KiB
Java
package mage.player.human;
|
|
|
|
import mage.MageIdentifier;
|
|
import mage.MageObject;
|
|
import mage.abilities.*;
|
|
import mage.abilities.costs.VariableCost;
|
|
import mage.abilities.costs.common.SacrificeSourceCost;
|
|
import mage.abilities.costs.common.TapSourceCost;
|
|
import mage.abilities.costs.mana.ManaCost;
|
|
import mage.abilities.costs.mana.ManaCostsImpl;
|
|
import mage.abilities.effects.Effects;
|
|
import mage.abilities.effects.RequirementEffect;
|
|
import mage.abilities.hint.HintUtils;
|
|
import mage.abilities.mana.ActivatedManaAbilityImpl;
|
|
import mage.abilities.mana.ManaAbility;
|
|
import mage.cards.*;
|
|
import mage.cards.decks.Deck;
|
|
import mage.choices.Choice;
|
|
import mage.choices.ChoiceHintType;
|
|
import mage.choices.ChoiceImpl;
|
|
import mage.constants.*;
|
|
import mage.filter.StaticFilters;
|
|
import mage.filter.common.FilterAttackingCreature;
|
|
import mage.filter.common.FilterBlockingCreature;
|
|
import mage.filter.common.FilterCreatureForCombat;
|
|
import mage.filter.common.FilterCreatureForCombatBlock;
|
|
import mage.filter.predicate.permanent.ControllerIdPredicate;
|
|
import mage.game.Game;
|
|
import mage.game.GameImpl;
|
|
import mage.game.combat.CombatGroup;
|
|
import mage.game.draft.Draft;
|
|
import mage.game.events.DeclareAttackerEvent;
|
|
import mage.game.match.Match;
|
|
import mage.game.permanent.Permanent;
|
|
import mage.game.stack.Spell;
|
|
import mage.game.tournament.Tournament;
|
|
import mage.players.Player;
|
|
import mage.players.PlayerImpl;
|
|
import mage.players.PlayerList;
|
|
import mage.players.net.UserData;
|
|
import mage.target.Target;
|
|
import mage.target.TargetAmount;
|
|
import mage.target.TargetCard;
|
|
import mage.target.common.TargetAttackingCreature;
|
|
import mage.target.common.TargetDefender;
|
|
import mage.target.targetpointer.TargetPointer;
|
|
import mage.util.*;
|
|
import mage.utils.SystemUtil;
|
|
import org.apache.log4j.Logger;
|
|
|
|
import java.awt.*;
|
|
import java.io.Serializable;
|
|
import java.util.List;
|
|
import java.util.*;
|
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
|
import java.util.stream.Collectors;
|
|
|
|
import static mage.constants.PlayerAction.REQUEST_AUTO_ANSWER_RESET_ALL;
|
|
import static mage.constants.PlayerAction.TRIGGER_AUTO_ORDER_RESET_ALL;
|
|
|
|
/**
|
|
* Human: server side logic to exchange game data between server app and another player's app
|
|
*
|
|
* @author BetaSteward_at_googlemail.com, JayDi85
|
|
*/
|
|
public class HumanPlayer extends PlayerImpl {
|
|
|
|
private static final boolean ALLOW_USERS_TO_PUT_NON_PLAYABLE_SPELLS_ON_STACK_WORKAROUND = false; // warning, see workaround's info on usage
|
|
|
|
// TODO: all user feedback actions executed and waited in diff threads and can't catch exceptions, e.g. on wrong code usage
|
|
// must catch and log such errors
|
|
|
|
// Network and threads logic:
|
|
// * starting point: ThreadExecutorImpl.java
|
|
// * server executing a game's code by single game thread (named: "GAME xxx", one thread per game)
|
|
// * data transferring goes by inner jboss threads (named: "WorkerThread xxx", one thread per client connection)
|
|
// - from server to client: sync mode, a single game thread uses jboss networking to send data to each player and viewer/watcher one by one
|
|
// - from client to server: async mode, each income command executes by shared call thread
|
|
//
|
|
// Client commands logic:
|
|
// * commands can do anything in server, user, table and game contexts
|
|
// * it's income in async mode at any time and in any order by "CALL xxx" threads
|
|
// * there are two types of client commands:
|
|
// - feedback commands - must be executed and synced by GAME thread (example: answer for choose dialog, priority, etc)
|
|
// - other commands - can be executed at any time and don't require sync with a game and a GAME thread (example: user/skip settings, concede/cheat, chat messages)
|
|
//
|
|
// Feedback commands logic:
|
|
// * income by CALL thread
|
|
// * must be synced and executed by GAME thread
|
|
// * keep only latest income feedback (if user sends multiple clicks/choices)
|
|
// * HumanPlayer contains "response" object for threads sync and data exchange
|
|
// * so sync logic:
|
|
// * - GAME thread: open response for income command and wait (go to sleep by response.wait)
|
|
// * - CALL thread: on closed response - waiting open status of player's response object (if it's too long then cancel the answer)
|
|
// * - CALL thread: on opened response - save answer to player's response object and notify GAME thread about it by response.notifyAll
|
|
// * - GAME thread: on notify from response - check new answer value and process it (if it bad then repeat and wait the next one);
|
|
private transient Boolean responseOpenedForAnswer = false; // GAME thread waiting new answer
|
|
private transient long responseLastWaitingThreadId = 0;
|
|
private final transient PlayerResponse response; // data receiver from a client side (must be shared for one player between multiple clients)
|
|
private final int RESPONSE_WAITING_TIME_SECS = 30; // waiting time before cancel current response
|
|
private final int RESPONSE_WAITING_CHECK_MS = 100; // timeout for open status check
|
|
|
|
protected static FilterCreatureForCombatBlock filterCreatureForCombatBlock = new FilterCreatureForCombatBlock();
|
|
protected static FilterCreatureForCombat filterCreatureForCombat = new FilterCreatureForCombat();
|
|
protected static FilterAttackingCreature filterAttack = new FilterAttackingCreature();
|
|
protected static FilterBlockingCreature filterBlock = new FilterBlockingCreature();
|
|
protected Choice replacementEffectChoice = null;
|
|
private static final Logger logger = Logger.getLogger(HumanPlayer.class);
|
|
|
|
protected HashSet<String> autoSelectReplacementEffects = new LinkedHashSet<>(); // must be sorted
|
|
protected ManaCost currentlyUnpaidMana;
|
|
|
|
protected Set<UUID> triggerAutoOrderAbilityFirst = new HashSet<>();
|
|
protected Set<UUID> triggerAutoOrderAbilityLast = new HashSet<>();
|
|
protected Set<String> triggerAutoOrderNameFirst = new HashSet<>();
|
|
protected Set<String> triggerAutoOrderNameLast = new HashSet<>();
|
|
|
|
// auto-answer
|
|
protected Map<String, Boolean> requestAutoAnswerId = new HashMap<>();
|
|
protected Map<String, Boolean> requestAutoAnswerText = new HashMap<>();
|
|
|
|
protected boolean holdingPriority;
|
|
|
|
protected ConcurrentLinkedQueue<PlayerResponse> actionQueue = new ConcurrentLinkedQueue<>();
|
|
protected ConcurrentLinkedQueue<PlayerResponse> actionQueueSaved = new ConcurrentLinkedQueue<>();
|
|
protected int actionIterations = 0;
|
|
protected boolean recordingMacro = false;
|
|
protected boolean macroTriggeredSelectionFlag;
|
|
protected boolean activatingMacro = false;
|
|
|
|
public HumanPlayer(String name, RangeOfInfluence range, int skill) {
|
|
super(name, range);
|
|
this.human = true;
|
|
this.response = new PlayerResponse();
|
|
initReplacementDialog();
|
|
}
|
|
|
|
private void initReplacementDialog() {
|
|
replacementEffectChoice = new ChoiceImpl(true);
|
|
replacementEffectChoice.setMessage("Choose replacement effect to resolve first");
|
|
replacementEffectChoice.setSpecial(
|
|
true,
|
|
false,
|
|
"Remember answer",
|
|
"Choose same answer next time (you can reset saved answers by battlefield popup menu)"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Make fake player from any other
|
|
*/
|
|
public HumanPlayer(final PlayerImpl sourcePlayer, final PlayerResponse sourceResponse) {
|
|
super(sourcePlayer);
|
|
this.human = true;
|
|
this.response = sourceResponse; // need for sync and wait user's response from a network
|
|
initReplacementDialog();
|
|
}
|
|
|
|
public HumanPlayer(final HumanPlayer player) {
|
|
super(player);
|
|
this.response = player.response;
|
|
|
|
this.replacementEffectChoice = player.replacementEffectChoice;
|
|
this.autoSelectReplacementEffects.addAll(player.autoSelectReplacementEffects);
|
|
this.currentlyUnpaidMana = player.currentlyUnpaidMana;
|
|
|
|
this.triggerAutoOrderAbilityFirst.addAll(player.triggerAutoOrderAbilityFirst);
|
|
this.triggerAutoOrderAbilityLast.addAll(player.triggerAutoOrderAbilityLast);
|
|
this.triggerAutoOrderNameFirst.addAll(player.triggerAutoOrderNameFirst);
|
|
this.triggerAutoOrderNameLast.addAll(player.triggerAutoOrderNameLast);
|
|
|
|
this.requestAutoAnswerId.putAll(player.requestAutoAnswerId);
|
|
this.requestAutoAnswerText.putAll(player.requestAutoAnswerText);
|
|
|
|
this.holdingPriority = player.holdingPriority;
|
|
|
|
this.actionQueue.addAll(player.actionQueue);
|
|
this.actionQueueSaved.addAll(player.actionQueueSaved);
|
|
this.actionIterations = player.actionIterations;
|
|
this.recordingMacro = player.recordingMacro;
|
|
this.macroTriggeredSelectionFlag = player.macroTriggeredSelectionFlag;
|
|
this.activatingMacro = player.activatingMacro;
|
|
}
|
|
|
|
protected boolean isExecutingMacro() {
|
|
return !recordingMacro
|
|
&& (!actionQueue.isEmpty()
|
|
|| (actionIterations > 0 && !actionQueueSaved.isEmpty()));
|
|
}
|
|
|
|
/**
|
|
* Waiting for opened response and save new value in it
|
|
* Use it in CALL threads only, e.g. for client commands
|
|
*
|
|
* @return on true result game can use response value, on false result - it's outdated response
|
|
*/
|
|
protected boolean waitResponseOpen() {
|
|
// client send commands in async mode and can come too early
|
|
// so if next command come too fast then wait here until game ready
|
|
//
|
|
// example with too early response:
|
|
// * game must send new update to 3 users and ask user 2 for feedback answer, but user 3 is too slow
|
|
// +0 secs: start sending data to 3 players
|
|
// +0 secs: user 1 getting data
|
|
// +1 secs: user 1 done
|
|
// +1 secs: user 2 getting data
|
|
// +3 secs: user 2 done
|
|
// +3 secs: user 3 getting data (it's slow)
|
|
// +4 secs: user 2 sent answer 1 (but game closed for it, so waiting)
|
|
// +5 secs: user 2 sent answer 2 (he can't see changes after answer 1, so trying again - server must keep only latest answer)
|
|
// +8 secs: user 3 done
|
|
// +8 secs: game start wating a new answer from user 2
|
|
// +8 secs: game find answer
|
|
|
|
int currentTimesWaiting = 0;
|
|
int maxTimesWaiting = RESPONSE_WAITING_TIME_SECS * 1000 / RESPONSE_WAITING_CHECK_MS;
|
|
long currentThreadId = Thread.currentThread().getId();
|
|
// it's a latest response
|
|
responseLastWaitingThreadId = currentThreadId;
|
|
while (!responseOpenedForAnswer && canRespond()) {
|
|
if (responseLastWaitingThreadId != currentThreadId) {
|
|
// there is another latest response, so cancel current
|
|
return false;
|
|
}
|
|
|
|
// keep waiting
|
|
currentTimesWaiting++;
|
|
if (currentTimesWaiting > maxTimesWaiting) {
|
|
// game frozen, possible reasons:
|
|
// * ANOTHER player lost connection and GAME thread trying to send data to him
|
|
// * current player send answer, but lost connect after it
|
|
// * game thread stops and lost
|
|
String possibleReason;
|
|
if (response.getActiveAction() == null) {
|
|
possibleReason = "maybe connection problem with another player/watcher";
|
|
} else {
|
|
possibleReason = "something wrong with your priority on " + response.getActiveAction();
|
|
}
|
|
logger.warn(String.format("Game frozen in waitResponseOpen for %d secs. User: %s; reason: %s; game: %s",
|
|
RESPONSE_WAITING_CHECK_MS * currentTimesWaiting / 1000,
|
|
this.getName(),
|
|
possibleReason,
|
|
response.getActiveGameInfo()
|
|
));
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
Thread.sleep(RESPONSE_WAITING_CHECK_MS);
|
|
} catch (InterruptedException ignore) {
|
|
}
|
|
}
|
|
|
|
return true; // can use new value
|
|
}
|
|
|
|
protected boolean pullResponseFromQueue(Game game) {
|
|
if (actionQueue.isEmpty() && actionIterations > 0 && !actionQueueSaved.isEmpty()) {
|
|
actionQueue = new ConcurrentLinkedQueue<>(actionQueueSaved);
|
|
actionIterations--;
|
|
// logger.info("MACRO iteration: " + actionIterations);
|
|
}
|
|
PlayerResponse action = actionQueue.poll();
|
|
if (action != null) {
|
|
if (action.getString() != null
|
|
&& action.getString().equals("resolveStack")) {
|
|
action = actionQueue.poll();
|
|
if (action == null) {
|
|
return false;
|
|
}
|
|
sendPlayerAction(PlayerAction.PASS_PRIORITY_UNTIL_STACK_RESOLVED, game, null);
|
|
}
|
|
//waitResponseOpen(); // it's a macro action, no need it here?
|
|
synchronized (response) {
|
|
response.copyFrom(action);
|
|
response.notifyAll();
|
|
macroTriggeredSelectionFlag = false;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Prepare priority player for new feedback, call it for every choose cycle before waitForResponse
|
|
*/
|
|
protected void prepareForResponse(Game game) {
|
|
ThreadUtils.ensureRunInGameThread();
|
|
|
|
// prepare priority player
|
|
// on null - it's a discard in cleanaup and other non-user code, so don't change it here at that moment
|
|
// TODO: must research null use case
|
|
if (game.getState().getPriorityPlayerId() != null) {
|
|
if (getId() == null) {
|
|
logger.fatal("Player with no ID: " + name);
|
|
this.quit(game);
|
|
return;
|
|
}
|
|
if (logger.isDebugEnabled()) {
|
|
logger.debug("Setting game priority for " + getId() + " [" + DebugUtil.getMethodNameWithSource(1, "method") + ']');
|
|
}
|
|
game.getState().setPriorityPlayerId(getId());
|
|
}
|
|
|
|
responseOpenedForAnswer = false;
|
|
}
|
|
|
|
/**
|
|
* Waiting feedback from priority player
|
|
*
|
|
* @param game
|
|
*/
|
|
protected void waitForResponse(Game game) {
|
|
ThreadUtils.ensureRunInGameThread();
|
|
;
|
|
|
|
if (isExecutingMacro()) {
|
|
pullResponseFromQueue(game);
|
|
// logger.info("MACRO pull from queue: " + response.toString());
|
|
// try {
|
|
// TimeUnit.MILLISECONDS.sleep(1000);
|
|
// } catch (InterruptedException e) {
|
|
// }
|
|
return;
|
|
}
|
|
|
|
boolean loop = true;
|
|
while (loop) {
|
|
// start waiting for next answer
|
|
response.clear();
|
|
response.setActiveAction(game, DebugUtil.getMethodNameWithSource(1, "method"));
|
|
game.resumeTimer(getTurnControlledBy());
|
|
responseOpenedForAnswer = true;
|
|
|
|
loop = false;
|
|
synchronized (response) { // TODO: synchronized response smells bad here, possible deadlocks? Need research
|
|
try {
|
|
response.wait(); // start waiting a response.notifyAll command from CALL thread (client answer)
|
|
} catch (InterruptedException ignore) {
|
|
} finally {
|
|
responseOpenedForAnswer = false;
|
|
game.pauseTimer(getTurnControlledBy());
|
|
}
|
|
}
|
|
|
|
// async command: concede by any player
|
|
// game recived immediately response on OTHER player concede -- need to process end game and continue to wait
|
|
// TODO: is it possible to break choose dialog of current player (check it in multiplayer)?
|
|
if (response.getAsyncWantConcede()) {
|
|
((GameImpl) game).checkConcede();
|
|
if (game.hasEnded()) {
|
|
return;
|
|
}
|
|
// wait another answer
|
|
if (canRespond()) {
|
|
loop = true;
|
|
}
|
|
}
|
|
|
|
// async command: cheat by current player
|
|
if (response.getAsyncWantCheat()) {
|
|
// run cheats
|
|
SystemUtil.executeCheatCommands(game, null, this);
|
|
// force to game update for new possible data
|
|
game.fireUpdatePlayersEvent();
|
|
// must stop current dialog on changed control, so game can give priority to actual player
|
|
if (this.isGameUnderControl() != game.getPlayer(this.getId()).isGameUnderControl()) {
|
|
return;
|
|
}
|
|
// wait another answer
|
|
if (canRespond()) {
|
|
loop = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (recordingMacro && !macroTriggeredSelectionFlag) {
|
|
actionQueueSaved.add(new PlayerResponse(response));
|
|
}
|
|
}
|
|
|
|
private boolean canCallFeedback(Game game) {
|
|
return !gameInCheckPlayableState(game) && !game.isSimulation();
|
|
}
|
|
|
|
@Override
|
|
public boolean chooseMulligan(Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
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");
|
|
String message;
|
|
if (getHand().size() > nextHandSize) {
|
|
// pay
|
|
message = "Mulligan " + HintUtils.prepareText("down to " + cardsCountInfo, Color.YELLOW) + "?";
|
|
} else {
|
|
// free
|
|
message = "Mulligan " + HintUtils.prepareText("for free", Color.GREEN) + ", draw another " + cardsCountInfo + "?";
|
|
}
|
|
Map<String, Serializable> options = new HashMap<>();
|
|
options.put("UI.left.btn.text", "Mulligan");
|
|
options.put("UI.right.btn.text", "Keep");
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireAskPlayerEvent(playerId, new MessageToClient(message), null, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
if (response.getBoolean() != null) {
|
|
return response.getBoolean();
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean chooseUse(Outcome outcome, String message, Ability source, Game game) {
|
|
return this.chooseUse(outcome, message, null, "Yes", "No", source, game);
|
|
}
|
|
|
|
@Override
|
|
public boolean chooseUse(Outcome outcome, String message, String secondMessage, String trueText, String falseText, Ability source, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
if (message == null) {
|
|
throw new IllegalArgumentException("Wrong code usage: main message is null");
|
|
}
|
|
|
|
MessageToClient messageToClient = new MessageToClient(message, secondMessage);
|
|
Map<String, Serializable> options = new HashMap<>(2);
|
|
String autoAnswerMessage = message; // Removes self-referential text for the purposes of auto-answering
|
|
if (trueText != null) {
|
|
options.put("UI.left.btn.text", trueText);
|
|
}
|
|
if (falseText != null) {
|
|
options.put("UI.right.btn.text", falseText);
|
|
}
|
|
if (source != null) {
|
|
//options.put(Constants.Option.ORIGINAL_ID, "")
|
|
String sourceName = getRelatedObjectName(source, game);
|
|
if (sourceName != null) {
|
|
autoAnswerMessage = autoAnswerMessage.replace(sourceName, "{this}");
|
|
}
|
|
}
|
|
|
|
options.put(Constants.Option.AUTO_ANSWER_MESSAGE, autoAnswerMessage);
|
|
|
|
// auto-answer
|
|
Boolean answer = null;
|
|
if (source != null) {
|
|
// ability + text
|
|
answer = requestAutoAnswerId
|
|
.get(source.getOriginalId() + "#" + autoAnswerMessage);
|
|
}
|
|
if (answer == null) {
|
|
// text
|
|
answer = requestAutoAnswerText.get(autoAnswerMessage);
|
|
}
|
|
if (answer != null) {
|
|
return answer;
|
|
}
|
|
|
|
while (canRespond()) {
|
|
if (messageToClient.getSecondMessage() == null) {
|
|
messageToClient.setSecondMessage(getRelatedObjectName(source, game));
|
|
}
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireAskPlayerEvent(playerId, messageToClient, source, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
if (response.getBoolean() != null) {
|
|
return response.getBoolean();
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private String getRelatedObjectName(Ability source, Game game) {
|
|
if (source != null) {
|
|
return getRelatedObjectName(source.getSourceId(), game);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private String getRelatedObjectName(UUID sourceId, Game game) {
|
|
MageObject mageObject = game.getObject(sourceId);
|
|
if (mageObject != null) {
|
|
return mageObject.getLogName();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private String addSecondLineWithObjectName(String message, UUID sourceId, Game game) {
|
|
if (sourceId != null) {
|
|
MageObject mageObject = game.getPermanent(sourceId);
|
|
if (mageObject == null) {
|
|
mageObject = game.getCard(sourceId);
|
|
}
|
|
if (mageObject != null) {
|
|
message += "<div style='font-size:11pt'>" + mageObject.getLogName() + "</div>";
|
|
}
|
|
}
|
|
return message;
|
|
}
|
|
|
|
@Override
|
|
public int chooseReplacementEffect(Map<String, String> effectsMap, Map<String, MageObject> objectsMap, Game game) {
|
|
if (gameInCheckPlayableState(game, true) || game.isSimulation()) { // TODO: ignore warning logs until double call for TAPPED_FOR_MANA will be fix
|
|
return 0;
|
|
}
|
|
|
|
if (effectsMap.size() <= 1) {
|
|
return 0;
|
|
}
|
|
|
|
// use auto-choice:
|
|
// - uses "always first" logic (choose in same order as user answer saves)
|
|
// - search same effects by text (object name [id]: rules)
|
|
// - autoSelectReplacementEffects is sorted set
|
|
// - must get "same settings" option between cycle/response (user can change it by preferences)
|
|
|
|
boolean useSameSettings = getControllingPlayersUserData(game).isUseSameSettingsForReplacementEffects();
|
|
if (!autoSelectReplacementEffects.isEmpty()) {
|
|
for (String autoText : autoSelectReplacementEffects) {
|
|
int count = 0;
|
|
// find effect with same saved text
|
|
for (String effectKey : effectsMap.keySet()) {
|
|
String currentText = prepareReplacementText(effectsMap.get(effectKey), useSameSettings);
|
|
if (currentText.equals(autoText)) {
|
|
return count;
|
|
}
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
replacementEffectChoice.clearChoice();
|
|
replacementEffectChoice.getChoices().clear();
|
|
replacementEffectChoice.getKeyChoices().clear();
|
|
effectsMap.forEach((key, value) -> {
|
|
MageObject object = objectsMap.getOrDefault(key, null);
|
|
replacementEffectChoice.withItem(
|
|
key,
|
|
value,
|
|
null,
|
|
object != null ? ChoiceHintType.GAME_OBJECT : null,
|
|
object != null ? object.getId().toString() : null
|
|
);
|
|
});
|
|
|
|
// if same choices then select first
|
|
int differentChoices = 0;
|
|
String lastChoice = "";
|
|
for (String value : replacementEffectChoice.getKeyChoices().values()) {
|
|
if (!lastChoice.equalsIgnoreCase(value)) {
|
|
lastChoice = value;
|
|
differentChoices++;
|
|
}
|
|
}
|
|
if (differentChoices <= 1) {
|
|
return 0;
|
|
}
|
|
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
replacementEffectChoice.onChooseStart(game, playerId);
|
|
game.fireChooseChoiceEvent(playerId, replacementEffectChoice);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
logger.debug("Choose effect: " + response.getString());
|
|
if (response.getString() != null) {
|
|
// save auto-choice (effect's text)
|
|
if (response.getString().startsWith("#")) {
|
|
replacementEffectChoice.setChoiceByKey(response.getString().substring(1), true);
|
|
if (replacementEffectChoice.isChosen()) {
|
|
// put auto-choice to the end
|
|
useSameSettings = getControllingPlayersUserData(game).isUseSameSettingsForReplacementEffects();
|
|
String effectText = prepareReplacementText(replacementEffectChoice.getChoiceValue(), useSameSettings);
|
|
autoSelectReplacementEffects.remove(effectText);
|
|
autoSelectReplacementEffects.add(effectText);
|
|
}
|
|
} else {
|
|
replacementEffectChoice.setChoiceByKey(response.getString(), false);
|
|
}
|
|
|
|
if (replacementEffectChoice.getChoiceKey() != null) {
|
|
int index = 0;
|
|
for (String key : effectsMap.keySet()) {
|
|
if (replacementEffectChoice.getChoiceKey().equals(key)) {
|
|
replacementEffectChoice.onChooseEnd(game, playerId, replacementEffectChoice.getChoiceKey());
|
|
return index;
|
|
}
|
|
index++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private String prepareReplacementText(String fullText, boolean useSameSettingsForDifferentObjects) {
|
|
// remove object id from the rules text (example: object [abd]: rules -> object : rules)
|
|
if (useSameSettingsForDifferentObjects) {
|
|
fullText = fullText.replaceAll("\\[\\w+\\]", "");
|
|
}
|
|
return fullText;
|
|
}
|
|
|
|
@Override
|
|
public boolean choose(Outcome outcome, Choice choice, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
if (choice.isKeyChoice() && choice.getKeyChoices().isEmpty()) {
|
|
throw new IllegalArgumentException("Wrong code usage. Key choices must contains some values");
|
|
}
|
|
if (!choice.isKeyChoice() && choice.getChoices().isEmpty()) {
|
|
throw new IllegalArgumentException("Wrong code usage. Choices must contains some values");
|
|
}
|
|
|
|
// Try to autopay for mana
|
|
if (Outcome.PutManaInPool == outcome && choice.isManaColorChoice() && currentlyUnpaidMana != null) {
|
|
// Check check if the spell being paid for cares about the color of mana being paid
|
|
// See: https://github.com/magefree/mage/issues/9070
|
|
boolean caresAboutManaColor = false;
|
|
if (!game.getStack().isEmpty() && game.getStack().getFirst() instanceof Spell) {
|
|
Spell spellBeingCast = (Spell) game.getStack().getFirst();
|
|
if (!spellBeingCast.isResolving() && spellBeingCast.getControllerId().equals(this.getId())) {
|
|
CardImpl card = (CardImpl) game.getCard(spellBeingCast.getSourceId());
|
|
caresAboutManaColor = card.caresAboutManaColor(game);
|
|
}
|
|
}
|
|
|
|
if (!caresAboutManaColor && ManaUtil.tryToAutoSelectAManaColor(choice, currentlyUnpaidMana)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
choice.onChooseStart(game, playerId);
|
|
game.fireChooseChoiceEvent(playerId, choice);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
String val = response.getString();
|
|
if (val != null && !val.isEmpty()) {
|
|
if (choice.isKeyChoice()) {
|
|
choice.setChoiceByKey(val);
|
|
} else {
|
|
choice.setChoice(val);
|
|
}
|
|
choice.onChooseEnd(game, playerId, val);
|
|
return true;
|
|
} else if (!choice.isRequired()) {
|
|
// cancel
|
|
choice.onChooseEnd(game, playerId, null);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean choose(Outcome outcome, Target target, Ability source, Game game) {
|
|
return choose(outcome, target, source, game, null);
|
|
}
|
|
|
|
@Override
|
|
public boolean choose(Outcome outcome, Target target, Ability source, Game game, Map<String, Serializable> options) {
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
// choose one or multiple targets
|
|
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
|
|
if (options == null) {
|
|
options = new HashMap<>();
|
|
}
|
|
|
|
// stop on completed, e.g. X=0
|
|
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
|
|
return false;
|
|
}
|
|
|
|
while (canRespond()) {
|
|
|
|
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;
|
|
}
|
|
|
|
// stop on impossible selection
|
|
if (required && !target.canChoose(abilityControllerId, source, game)) {
|
|
break;
|
|
}
|
|
|
|
// 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)), possibleTargets, required, getOptions(target, options));
|
|
}
|
|
waitForResponse(game);
|
|
UUID responseId = getFixedResponseUUID(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, game)) {
|
|
target.add(responseId, game);
|
|
if (target.isChoiceCompleted(abilityControllerId, source, game)) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// stop on done/cancel button press
|
|
if (target.isChosen(game)) {
|
|
break;
|
|
} else {
|
|
if (!required) {
|
|
// can stop at any moment
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// continue to next target
|
|
}
|
|
}
|
|
|
|
return target.isChosen(game) && target.getTargets().size() > 0;
|
|
}
|
|
|
|
@Override
|
|
public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
// choose one or multiple targets
|
|
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
|
|
Map<String, Serializable> options = new HashMap<>();
|
|
|
|
while (canRespond()) {
|
|
Set<UUID> possibleTargets = target.possibleTargets(abilityControllerId, source, game);
|
|
boolean required = target.isRequired(source != null ? source.getSourceId() : null, game);
|
|
if (possibleTargets.isEmpty()
|
|
|| target.getTargets().size() >= target.getMinNumberOfTargets()) {
|
|
required = false;
|
|
}
|
|
|
|
// auto-choose
|
|
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game);
|
|
|
|
// manual choice
|
|
if (responseId == null) {
|
|
options.put("chosenTargets", (Serializable) target.getTargets());
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireSelectTargetEvent(getId(), new MessageToClient(target.getMessage(game), getRelatedObjectName(source, game)),
|
|
possibleTargets, required, getOptions(target, options));
|
|
}
|
|
waitForResponse(game);
|
|
responseId = getFixedResponseUUID(game);
|
|
}
|
|
|
|
if (responseId != null) {
|
|
// remove old target
|
|
if (target.contains(responseId)) {
|
|
target.remove(responseId);
|
|
continue;
|
|
}
|
|
|
|
// add new target
|
|
if (possibleTargets.contains(responseId)) {
|
|
if (target.canTarget(abilityControllerId, responseId, source, game)) {
|
|
target.addTarget(responseId, source, 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return target.isChosen(game) && target.getTargets().size() > 0;
|
|
}
|
|
|
|
private Map<String, Serializable> getOptions(Target target, Map<String, Serializable> options) {
|
|
if (options == null) {
|
|
options = new HashMap<>();
|
|
}
|
|
if (target.getTargets().size() >= target.getMinNumberOfTargets()
|
|
&& !options.containsKey("UI.right.btn.text")) {
|
|
options.put("UI.right.btn.text", "Done");
|
|
}
|
|
options.put("targetZone", target.getZone());
|
|
return options;
|
|
}
|
|
|
|
@Override
|
|
public boolean choose(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
// ignore bad state
|
|
if (cards == null || cards.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
|
|
|
|
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
|
|
|| target.getTargets().size() >= target.getMinNumberOfTargets()) {
|
|
required = false;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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) target.getTargets());
|
|
if (!possibleTargets.isEmpty()) {
|
|
options.put("possibleTargets", (Serializable) possibleTargets);
|
|
}
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireSelectTargetEvent(playerId, new MessageToClient(target.getMessage(game)), cards, required, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(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.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;
|
|
}
|
|
}
|
|
}
|
|
// continue to next target
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// choose one or multiple target cards
|
|
@Override
|
|
public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
if (cards == null || cards.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
|
|
|
|
while (canRespond()) {
|
|
boolean required = target.isRequiredExplicitlySet() ? target.isRequired() : target.isRequired(source);
|
|
int count = cards.count(target.getFilter(), abilityControllerId, source, game);
|
|
if (count == 0
|
|
|| target.getTargets().size() >= target.getMinNumberOfTargets()) {
|
|
required = false;
|
|
}
|
|
|
|
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)
|
|
if (possibleTargets.isEmpty()) {
|
|
required = false;
|
|
}
|
|
|
|
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game, possibleTargets);
|
|
|
|
if (responseId == null) {
|
|
Map<String, Serializable> options = getOptions(target, null);
|
|
options.put("chosenTargets", (Serializable) target.getTargets());
|
|
|
|
if (!possibleTargets.isEmpty()) {
|
|
options.put("possibleTargets", (Serializable) possibleTargets);
|
|
}
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireSelectTargetEvent(playerId, new MessageToClient(target.getMessage(game), getRelatedObjectName(source, game)), cards, required, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
responseId = getFixedResponseUUID(game);
|
|
}
|
|
|
|
if (responseId != null) {
|
|
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.isChoiceCompleted(abilityControllerId, source, game)) {
|
|
return true;
|
|
}
|
|
}
|
|
} else {
|
|
if (target.getTargets().size() >= target.getMinNumberOfTargets()) {
|
|
return true;
|
|
}
|
|
if (!required) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) {
|
|
// choose amount
|
|
// human can choose or un-choose MULTIPLE targets at once
|
|
if (!canCallFeedback(game)) {
|
|
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 = target.getAffectedAbilityControllerId(this.getId());
|
|
|
|
int amountTotal = target.getAmountTotal(game, source);
|
|
if (amountTotal == 0) {
|
|
return false; // nothing to distribute
|
|
}
|
|
MultiAmountType multiAmountType = source.getRule().contains("damage") ? MultiAmountType.DAMAGE : MultiAmountType.P1P1;
|
|
|
|
// 601.2d. If the spell requires the player to divide or distribute an effect (such as damage or counters)
|
|
// among one or more targets, the player announces the division.
|
|
// Each of these targets must receive at least one of whatever is being divided.
|
|
|
|
// Two steps logic:
|
|
// 1. Select targets
|
|
// 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);
|
|
if (possibleTargetIds.isEmpty()
|
|
|| target.getSize() >= target.getMinNumberOfTargets()) {
|
|
required = false;
|
|
}
|
|
|
|
UUID responseId = target.tryToAutoChoose(abilityControllerId, source, game);
|
|
|
|
// responseId is null if a choice couldn't be automatically made
|
|
if (responseId == null) {
|
|
List<UUID> possibleTargets = new ArrayList<>();
|
|
for (UUID targetId : possibleTargetIds) {
|
|
if (target.canTarget(abilityControllerId, targetId, source, game)) {
|
|
possibleTargets.add(targetId);
|
|
}
|
|
}
|
|
// if nothing to choose then show dialog (user must see non selectable items and click on any of them)
|
|
if (required && possibleTargets.isEmpty()) {
|
|
required = false;
|
|
}
|
|
|
|
// selected
|
|
Map<String, Serializable> options = getOptions(target, null);
|
|
options.put("chosenTargets", (Serializable) target.getTargets());
|
|
if (!possibleTargets.isEmpty()) {
|
|
options.put("possibleTargets", (Serializable) possibleTargets);
|
|
}
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
String multiType = multiAmountType == MultiAmountType.DAMAGE ? " to divide %d damage" : " to distribute %d counters";
|
|
String message = target.getMessage(game) + String.format(multiType, amountTotal);
|
|
game.fireSelectTargetEvent(playerId, new MessageToClient(message, getRelatedObjectName(source, game)), possibleTargetIds, required, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
responseId = getFixedResponseUUID(game);
|
|
}
|
|
|
|
if (responseId != null) {
|
|
if (target.contains(responseId)) {
|
|
// unselect
|
|
target.remove(responseId);
|
|
} else if (possibleTargetIds.contains(responseId)
|
|
&& target.canTarget(abilityControllerId, responseId, source, game)
|
|
&& target.getSize() < amountTotal) {
|
|
// select
|
|
target.addTarget(responseId, source, game);
|
|
}
|
|
} else if (!required) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// no targets to choose or disconnected
|
|
List<UUID> targets = target.getTargets();
|
|
if (targets.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
// 2. Distribute amount between selected targets
|
|
|
|
// if only one target, it gets full amount, no possible choice
|
|
if (targets.size() == 1) {
|
|
target.setTargetAmount(targets.get(0), amountTotal, source, game);
|
|
return true;
|
|
}
|
|
|
|
// if number of targets equal to amount, each get 1, no possible choice
|
|
if (targets.size() == amountTotal) {
|
|
for (UUID targetId : targets) {
|
|
target.setTargetAmount(targetId, 1, source, game);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// should not be able to have more targets than amount, but in such case it's illegal
|
|
if (targets.size() > amountTotal) {
|
|
target.clearChosen();
|
|
return false;
|
|
}
|
|
|
|
// prepare targets list with p/t or life stats (cause that's dialog used for damage distribute)
|
|
List<String> targetNames = new ArrayList<>();
|
|
for (UUID targetId : targets) {
|
|
MageObject targetObject = game.getObject(targetId);
|
|
if (targetObject != null) {
|
|
targetNames.add(String.format("%s, P/T: %d/%d",
|
|
targetObject.getLogName(),
|
|
targetObject.getPower().getValue(),
|
|
targetObject.getToughness().getValue()
|
|
));
|
|
} else {
|
|
Player player = game.getPlayer(targetId);
|
|
if (player != null) {
|
|
targetNames.add(String.format("%s, life: %d", player.getName(), player.getLife()));
|
|
} else {
|
|
targetNames.add("ERROR, unknown target " + targetId.toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
// ask and assign new amount
|
|
List<Integer> targetValues = getMultiAmount(outcome, targetNames, 1, amountTotal, amountTotal, multiAmountType, game);
|
|
for (int i = 0; i < targetValues.size(); i++) {
|
|
int newAmount = targetValues.get(i);
|
|
UUID targetId = targets.get(i);
|
|
if (newAmount <= 0) {
|
|
// remove target
|
|
target.remove(targetId);
|
|
} else {
|
|
// set amount
|
|
target.setTargetAmount(targetId, newAmount, source, game);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public boolean priority(Game game) {
|
|
passed = false;
|
|
// TODO: fix problems with turn under out control:
|
|
// 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.getControllingPlayersUserData(game);
|
|
if (canRespond()) {
|
|
// TODO: check that all skips and stops used from real controlling player
|
|
// like holdingPriority (is it a bug here?)
|
|
if (getJustActivatedType() != null && !holdingPriority) {
|
|
if (controllingUserData.isPassPriorityCast()
|
|
&& getJustActivatedType() == AbilityType.SPELL) {
|
|
setJustActivatedType(null);
|
|
pass(game);
|
|
return false;
|
|
}
|
|
if (controllingUserData.isPassPriorityActivation()
|
|
&& getJustActivatedType().isNonManaActivatedAbility()) {
|
|
setJustActivatedType(null);
|
|
pass(game);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// STOP conditions (temporary stop without skip reset)
|
|
boolean quickStop = false;
|
|
if (isGameUnderControl()) { // TODO: remove to enable quick stop for controlling player
|
|
// if was attacked - always stop BEFORE blocker step (to cast extra spells)
|
|
if (game.getTurnStepType() == PhaseStep.DECLARE_ATTACKERS
|
|
&& game.getCombat().getPlayerDefenders(game).contains(playerId)) {
|
|
FilterCreatureForCombatBlock filter = filterCreatureForCombatBlock.copy();
|
|
filter.add(new ControllerIdPredicate(playerId));
|
|
// stop skip on any/zero permanents available
|
|
int possibleBlockersCount = game.getBattlefield().count(filter, playerId, null, game);
|
|
boolean canStopOnAny = possibleBlockersCount != 0 && getControllingPlayersUserData(game).getUserSkipPrioritySteps().isStopOnDeclareBlockersWithAnyPermanents();
|
|
boolean canStopOnZero = possibleBlockersCount == 0 && getControllingPlayersUserData(game).getUserSkipPrioritySteps().isStopOnDeclareBlockersWithZeroPermanents();
|
|
quickStop = canStopOnAny || canStopOnZero;
|
|
}
|
|
}
|
|
|
|
// SKIP - use the skip actions only if the player itself controls its turn
|
|
if (!quickStop && isGameUnderControl()) { // TODO: remove to enable skips for controlling player
|
|
|
|
if (passedAllTurns || passedTurnSkipStack) {
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (passedUntilEndStepBeforeMyTurn) {
|
|
if (game.getTurnStepType() != PhaseStep.END_TURN) {
|
|
// other step
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
// end step - search yourself
|
|
PlayerList playerList = game.getState().getPlayerList(playerId);
|
|
if (!playerList.getPrevious().equals(game.getActivePlayerId())) {
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
// stop
|
|
passedUntilEndStepBeforeMyTurn = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (game.getStack().isEmpty()) {
|
|
// empty stack
|
|
|
|
boolean dontCheckPassStep = false;
|
|
if (passedUntilStackResolved) { // Don't skip to next step with this action. It always only resolves a stack. If stack is empty it does nothing.
|
|
passedUntilStackResolved = false;
|
|
dontCheckPassStep = true;
|
|
}
|
|
|
|
if (passedTurn
|
|
|| passedTurnSkipStack) {
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (passedUntilNextMain) {
|
|
if (game.getTurnStepType() == PhaseStep.POSTCOMBAT_MAIN
|
|
|| game.getTurnStepType() == PhaseStep.PRECOMBAT_MAIN) {
|
|
// it's main step
|
|
if (!skippedAtLeastOnce
|
|
|| (!playerId.equals(game.getActivePlayerId())
|
|
&& !controllingUserData.getUserSkipPrioritySteps().isStopOnAllMainPhases())) {
|
|
skippedAtLeastOnce = true;
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
dontCheckPassStep = true;
|
|
passedUntilNextMain = false; // reset skip action
|
|
}
|
|
} else {
|
|
skippedAtLeastOnce = true;
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (passedUntilEndOfTurn) {
|
|
if (game.getTurnStepType() == PhaseStep.END_TURN) {
|
|
// it's end of turn step
|
|
if (!skippedAtLeastOnce
|
|
|| (playerId.equals(game.getActivePlayerId())
|
|
&& !controllingUserData
|
|
.getUserSkipPrioritySteps()
|
|
.isStopOnAllEndPhases())) {
|
|
skippedAtLeastOnce = true;
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
dontCheckPassStep = true;
|
|
passedUntilEndOfTurn = false; // reset skip action
|
|
}
|
|
} else {
|
|
skippedAtLeastOnce = true;
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!dontCheckPassStep
|
|
&& checkPassStep(game, controllingUserData)) {
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
} else {
|
|
// non empty stack
|
|
boolean haveNewObjectsOnStack = !Objects.equals(dateLastAddedToStack, game.getStack().getDateLastAdded());
|
|
dateLastAddedToStack = game.getStack().getDateLastAdded();
|
|
if (passedUntilStackResolved) {
|
|
if (haveNewObjectsOnStack
|
|
&& (playerId.equals(game.getActivePlayerId())
|
|
&& controllingUserData
|
|
.getUserSkipPrioritySteps()
|
|
.isStopOnStackNewObjects())) {
|
|
// new objects on stack -- disable "pass until stack resolved"
|
|
passedUntilStackResolved = false;
|
|
} else {
|
|
// no new objects on stack -- go to next priority
|
|
}
|
|
}
|
|
if (passedUntilStackResolved) {
|
|
if (passWithManaPoolCheck(game)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
while (canRespond()) {
|
|
holdingPriority = false;
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.firePriorityEvent(playerId);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
if (game.executingRollback()) {
|
|
return true;
|
|
}
|
|
|
|
if (response.getBoolean() != null
|
|
|| response.getInteger() != null) {
|
|
if (!activatingMacro && passWithManaPoolCheck(game)) {
|
|
return false;
|
|
} else {
|
|
if (activatingMacro) {
|
|
synchronized (actionQueue) {
|
|
actionQueue.notifyAll();
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (response.getString() != null
|
|
&& response.getString().equals("special")) {
|
|
activateSpecialAction(game, null);
|
|
} else if (responseId != null) {
|
|
boolean result = false;
|
|
MageObject object = game.getObject(responseId);
|
|
if (object != null) {
|
|
Zone zone = game.getState().getZone(object.getId());
|
|
if (zone != null) {
|
|
// look at card or try to cast/activate abilities
|
|
Map<UUID, ActivatedAbility> useableAbilities = new LinkedHashMap<>();
|
|
|
|
Player actingPlayer = null;
|
|
if (playerId.equals(game.getPriorityPlayerId())) {
|
|
actingPlayer = this;
|
|
} else if (getPlayersUnderYourControl().contains(game.getPriorityPlayerId())) {
|
|
actingPlayer = game.getPlayer(game.getPriorityPlayerId());
|
|
}
|
|
if (actingPlayer != null) {
|
|
useableAbilities = actingPlayer.getPlayableActivatedAbilities(object, zone, game);
|
|
|
|
// GUI: workaround to enable users to put spells on stack without real available mana
|
|
// (without highlighting, like it was in old versions before June 2020)
|
|
// Reason: some gain ability adds cost modification and other things to spells on stack only,
|
|
// e.g. xmage can't find playable ability before put that spell on stack (wtf example: Chief Engineer,
|
|
// see ConvokeTest)
|
|
// TODO: it's a BAD workaround -- users can't see that card/ability is broken and will not report to us, AI can't play that ability too
|
|
// Enable it on massive broken cards/abilities only or for manual tests
|
|
if (ALLOW_USERS_TO_PUT_NON_PLAYABLE_SPELLS_ON_STACK_WORKAROUND) {
|
|
if (object instanceof Card) {
|
|
for (Ability ability : ((Card) object).getAbilities(game)) {
|
|
if (ability instanceof SpellAbility && ((SpellAbility) ability).canActivate(actingPlayer.getId(), game).canActivate()
|
|
|| ability instanceof PlayLandAbility) {
|
|
useableAbilities.putIfAbsent(ability.getId(), (ActivatedAbility) ability);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (object instanceof Card
|
|
&& ((Card) object).isFaceDown(game)
|
|
&& lookAtFaceDownCard((Card) object, game, useableAbilities.size())) {
|
|
result = true;
|
|
} else {
|
|
if (!useableAbilities.isEmpty()) {
|
|
activateAbility(useableAbilities, object, game);
|
|
result = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
} else {
|
|
return response.getManaType() == null;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private UUID getFixedResponseUUID(Game game) {
|
|
// user can clicks on any side of multi/double faces card, but game must process click to main card all the time
|
|
MageObject object = game.getObject(response.getUUID());
|
|
|
|
// mdf cards
|
|
if (object instanceof ModalDoubleFacedCardHalf) {
|
|
if (!Zone.BATTLEFIELD.equals(game.getState().getZone(object.getId()))
|
|
&& !Zone.STACK.equals(game.getState().getZone(object.getId()))) {
|
|
return ((ModalDoubleFacedCardHalf) object).getMainCard().getId();
|
|
}
|
|
}
|
|
|
|
return response.getUUID();
|
|
}
|
|
|
|
private boolean checkPassStep(Game game, UserData controllingUserData) {
|
|
try {
|
|
|
|
if (playerId.equals(game.getActivePlayerId())) {
|
|
return !controllingUserData.getUserSkipPrioritySteps().getYourTurn().isPhaseStepSet(game.getTurnStepType());
|
|
} else {
|
|
return !controllingUserData.getUserSkipPrioritySteps().getOpponentTurn().isPhaseStepSet(game.getTurnStepType());
|
|
}
|
|
} catch (NullPointerException ex) {
|
|
if (controllingUserData != null) {
|
|
if (controllingUserData.getUserSkipPrioritySteps() != null) {
|
|
if (game.getStep() != null) {
|
|
if (game.getTurnStepType() == null) {
|
|
logger.error("game.getTurnStepType() == null");
|
|
}
|
|
} else {
|
|
logger.error("game.getStep() == null");
|
|
}
|
|
} else {
|
|
logger.error("UserData.getUserSkipPrioritySteps == null");
|
|
}
|
|
} else {
|
|
logger.error("UserData == null");
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public TriggeredAbility chooseTriggeredAbility(java.util.List<TriggeredAbility> abilities, Game game) {
|
|
// choose triggered ability from list
|
|
if (!canCallFeedback(game)) {
|
|
return abilities.isEmpty() ? null : abilities.get(0);
|
|
}
|
|
|
|
// automatically order triggers with same ability, rules text, and targets
|
|
String autoOrderRuleText = null;
|
|
Ability autoOrderAbility = null;
|
|
boolean autoOrderUse = getControllingPlayersUserData(game).isAutoOrderTrigger();
|
|
while (canRespond()) {
|
|
// try to set trigger auto order
|
|
List<TriggeredAbility> abilitiesWithNoOrderSet = new ArrayList<>();
|
|
List<TriggeredAbility> abilitiesOrderLast = new ArrayList<>();
|
|
for (TriggeredAbility ability : abilities) {
|
|
if (triggerAutoOrderAbilityFirst.contains(ability.getOriginalId())) {
|
|
return ability;
|
|
}
|
|
MageObject object = game.getObject(ability.getSourceId());
|
|
String rule = ability.getRule(object != null ? object.getName() : null);
|
|
if (triggerAutoOrderNameFirst.contains(rule)) {
|
|
return ability;
|
|
}
|
|
if (triggerAutoOrderAbilityLast.contains(ability.getOriginalId())) {
|
|
// multiple instances of same trigger has same originalId, no need to select order for it
|
|
abilitiesOrderLast.add(ability);
|
|
continue;
|
|
}
|
|
if (triggerAutoOrderNameLast.contains(rule)) {
|
|
abilitiesOrderLast.add(ability);
|
|
continue;
|
|
}
|
|
if (autoOrderUse) {
|
|
// multiple triggers with same rule text will be auto-ordered if possible
|
|
// if different, must use choose dialog
|
|
if (autoOrderRuleText == null) {
|
|
// first trigger, store rule text and ability to compare subsequent triggers
|
|
autoOrderRuleText = rule;
|
|
autoOrderAbility = ability;
|
|
} else if (!rule.equals(autoOrderRuleText)) {
|
|
// disable auto order if rule text is different
|
|
autoOrderUse = false;
|
|
} else {
|
|
// disable auto order if targets are different
|
|
Effects effects1 = autoOrderAbility.getEffects();
|
|
Effects effects2 = ability.getEffects();
|
|
if (effects1.size() != effects2.size()) {
|
|
autoOrderUse = false;
|
|
} else {
|
|
for (int i = 0; i < effects1.size(); i++) {
|
|
TargetPointer targetPointer1 = effects1.get(i).getTargetPointer();
|
|
TargetPointer targetPointer2 = effects2.get(i).getTargetPointer();
|
|
List<UUID> targets1 = (targetPointer1 == null) ? new ArrayList<>() : targetPointer1.getTargets(game, autoOrderAbility);
|
|
List<UUID> targets2 = (targetPointer2 == null) ? new ArrayList<>() : targetPointer2.getTargets(game, ability);
|
|
if (!targets1.equals(targets2)) {
|
|
autoOrderUse = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
abilitiesWithNoOrderSet.add(ability);
|
|
}
|
|
|
|
if (abilitiesWithNoOrderSet.isEmpty()) {
|
|
// user can send diff abilities to the last, will be selected by "first" like first ordered ability above
|
|
return abilitiesOrderLast.stream().findFirst().orElse(null);
|
|
}
|
|
|
|
if (abilitiesWithNoOrderSet.size() == 1 || autoOrderUse) {
|
|
return abilitiesWithNoOrderSet.iterator().next();
|
|
}
|
|
|
|
// runtime check: lost triggers for GUI
|
|
List<Ability> processingAbilities = new ArrayList<>(abilitiesWithNoOrderSet);
|
|
processingAbilities.addAll(abilitiesOrderLast);
|
|
|
|
if (abilities.size() != processingAbilities.size()) {
|
|
throw new IllegalStateException(String.format("Choose dialog lost some of the triggered abilities:\n"
|
|
+ "Must %d:\n%s\n"
|
|
+ "Has %d:\n%s",
|
|
abilities.size(),
|
|
abilities.stream().map(Ability::getRule).collect(Collectors.joining("\n")),
|
|
processingAbilities.size(),
|
|
processingAbilities.stream().map(Ability::getRule).collect(Collectors.joining("\n"))
|
|
));
|
|
}
|
|
|
|
macroTriggeredSelectionFlag = true;
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireSelectTargetTriggeredAbilityEvent(playerId, "Pick triggered ability (goes to the stack first)", abilitiesWithNoOrderSet);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (responseId != null) {
|
|
for (TriggeredAbility ability : abilitiesWithNoOrderSet) {
|
|
if (ability.getId().equals(responseId)
|
|
|| (!macroTriggeredSelectionFlag
|
|
&& ability.getSourceId().equals(responseId))) {
|
|
if (recordingMacro) {
|
|
PlayerResponse tResponse = new PlayerResponse();
|
|
tResponse.setUUID(ability.getSourceId());
|
|
actionQueueSaved.add(tResponse);
|
|
logger.debug("Adding Triggered Ability Source: " + tResponse);
|
|
}
|
|
macroTriggeredSelectionFlag = false;
|
|
return ability;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
macroTriggeredSelectionFlag = false;
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public boolean playMana(Ability abilityToCast, ManaCost unpaid, String promptText, Game game) {
|
|
payManaMode = true;
|
|
try {
|
|
return playManaHandling(abilityToCast, unpaid, promptText, game);
|
|
} finally {
|
|
payManaMode = false;
|
|
}
|
|
}
|
|
|
|
protected boolean playManaHandling(Ability abilityToCast, ManaCost unpaid, String promptText, Game game) {
|
|
// choose mana to pay (from permanents or from pool)
|
|
if (!canCallFeedback(game)) {
|
|
return false;
|
|
}
|
|
|
|
// TODO: make canRespond cycle?
|
|
if (canRespond()) {
|
|
Map<String, Serializable> options = new HashMap<>();
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.firePlayManaEvent(playerId, "Pay " + promptText, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (response.getBoolean() != null) {
|
|
// cancel
|
|
return false;
|
|
} else if (responseId != null) {
|
|
// pay from mana object
|
|
playManaAbilities(responseId, abilityToCast, unpaid, game);
|
|
} else if (response.getString() != null && response.getString().equals("special")) {
|
|
// pay from special action
|
|
if (unpaid instanceof ManaCostsImpl) {
|
|
activateSpecialAction(game, unpaid);
|
|
}
|
|
} else if (response.getManaType() != null) {
|
|
// pay from own mana pool
|
|
if (response.getManaPlayerId().equals(this.getId())) {
|
|
this.getManaPool().unlockManaType(response.getManaType());
|
|
}
|
|
// TODO: Handle if mana pool
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false; // must return false to stop mana pay cycle with non responding player
|
|
}
|
|
|
|
/**
|
|
* Gets the number of times the user wants to repeat their macro
|
|
*
|
|
* @param game
|
|
* @return
|
|
*/
|
|
public int announceRepetitions(Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return 0;
|
|
}
|
|
|
|
int xValue = 0;
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
game.fireGetAmountEvent(playerId, "How many times do you want to repeat your shortcut?", 0, 999);
|
|
waitForResponse(game);
|
|
|
|
if (response.getInteger() != null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (response.getInteger() != null) {
|
|
xValue = response.getInteger();
|
|
}
|
|
return xValue;
|
|
}
|
|
|
|
/**
|
|
* Gets the amount of mana the player want to spend for an x spell
|
|
*/
|
|
@Override
|
|
public int announceX(int min, int max, String message, Game game, Ability source, boolean isManaPay) {
|
|
if (!canCallFeedback(game)) {
|
|
return min;
|
|
}
|
|
|
|
// fast calc on nothing to choose
|
|
if (min >= max) {
|
|
return min;
|
|
}
|
|
|
|
int xValue = min;
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireGetAmountEvent(playerId, message + CardUtil.getSourceLogName(game, source), min, max);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
if (response.getInteger() == null) {
|
|
continue;
|
|
}
|
|
|
|
xValue = response.getInteger();
|
|
if (xValue < min || xValue > max) {
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return xValue;
|
|
}
|
|
|
|
protected void playManaAbilities(UUID objectId, Ability abilityToCast, ManaCost unpaid, Game game) {
|
|
MageObject object = game.getObject(objectId);
|
|
if (object == null) {
|
|
return;
|
|
}
|
|
|
|
// GUI: for user's information only - check if mana abilities allows to use here (getUseableManaAbilities already filter it)
|
|
// Reason: when you use special mana ability then normal mana abilities will be restricted to pay. Users
|
|
// can't see lands as playable and must know the reason (if they click on land then they get that message)
|
|
if (abilityToCast.getAbilityType() == AbilityType.SPELL) {
|
|
Spell spell = game.getStack().getSpell(abilityToCast.getSourceId());
|
|
boolean haveManaAbilities = object.getAbilities().stream().anyMatch(ManaAbility.class::isInstance);
|
|
if (spell != null && !spell.isResolving() && haveManaAbilities) {
|
|
switch (spell.getCurrentActivatingManaAbilitiesStep()) {
|
|
// if you used special mana ability like convoke then normal mana abilities will be restricted to use, see Convoke for details
|
|
case BEFORE:
|
|
case NORMAL:
|
|
break;
|
|
case AFTER:
|
|
game.informPlayer(this, "You can no longer use activated mana abilities to pay for the current spell (special mana pay already used). Cancel and recast the spell to activate mana abilities first.");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
Zone zone = game.getState().getZone(object.getId());
|
|
if (zone != null) {
|
|
Map<UUID, ActivatedManaAbilityImpl> useableAbilities = getUseableManaAbilities(object, zone, game);
|
|
if (!useableAbilities.isEmpty()) {
|
|
// Added to ensure that mana is not being autopaid for spells that care about the color of mana being paid
|
|
// See https://github.com/magefree/mage/issues/9070
|
|
boolean caresAboutManaColor = false;
|
|
if (abilityToCast.getAbilityType() == AbilityType.SPELL) {
|
|
CardImpl card = (CardImpl) game.getCard(abilityToCast.getSourceId());
|
|
caresAboutManaColor = card.caresAboutManaColor(game);
|
|
}
|
|
|
|
// Don't auto-pay if the spell cares about the color
|
|
if (!caresAboutManaColor) {
|
|
useableAbilities = ManaUtil.tryToAutoPay(unpaid, useableAbilities); // eliminates other abilities if one fits perfectly
|
|
}
|
|
currentlyUnpaidMana = unpaid;
|
|
activateAbility(useableAbilities, object, game);
|
|
currentlyUnpaidMana = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void selectAttackers(Game game, UUID attackingPlayerId) {
|
|
if (!canCallFeedback(game)) {
|
|
return;
|
|
}
|
|
|
|
FilterCreatureForCombat filter = filterCreatureForCombat.copy();
|
|
filter.add(new ControllerIdPredicate(attackingPlayerId));
|
|
|
|
while (canRespond()) {
|
|
java.util.List<UUID> possibleAttackers = new ArrayList<>();
|
|
for (Permanent possibleAttacker : game.getBattlefield().getActivePermanents(filter, attackingPlayerId, game)) {
|
|
if (possibleAttacker.canAttack(null, game)) {
|
|
possibleAttackers.add(possibleAttacker.getId());
|
|
}
|
|
}
|
|
|
|
// skip declare attack step
|
|
// old version:
|
|
// - passedAllTurns, passedUntilEndStepBeforeMyTurn: always skipped
|
|
// - other: on disabled option skipped
|
|
if (passedAllTurns
|
|
|| passedUntilEndStepBeforeMyTurn
|
|
|| (!getControllingPlayersUserData(game)
|
|
.getUserSkipPrioritySteps()
|
|
.isStopOnDeclareAttackers()
|
|
&& (passedTurn
|
|
|| passedTurnSkipStack
|
|
|| passedUntilEndOfTurn
|
|
|| passedUntilNextMain))) {
|
|
if (checkIfAttackersValid(game)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
/*
|
|
// new version:
|
|
// - all: on disabled option skipped (if attackers selected)
|
|
if (!getControllingPlayersUserData(game)
|
|
.getUserSkipPrioritySteps()
|
|
.isStopOnDeclareAttackers()
|
|
&& (possibleAttackers.size() > 0)) {
|
|
if (checkIfAttackersValid(game)) {
|
|
return;
|
|
}
|
|
}
|
|
*/
|
|
Map<String, Serializable> options = new HashMap<>();
|
|
options.put(Constants.Option.POSSIBLE_ATTACKERS, (Serializable) possibleAttackers);
|
|
if (!possibleAttackers.isEmpty()) {
|
|
options.put(Constants.Option.SPECIAL_BUTTON, "All attack");
|
|
}
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireSelectEvent(playerId, "Select attackers", options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (response.getString() != null
|
|
&& response.getString().equals("special")) { // All attack
|
|
setStoredBookmark(game.bookmarkState());
|
|
UUID attackedDefender = null;
|
|
if (game.getCombat().getDefenders().size() > 1) {
|
|
attackedDefender = selectDefenderForAllAttack(game.getCombat().getDefenders(), game);
|
|
} else if (game.getCombat().getDefenders().size() == 1) {
|
|
attackedDefender = game.getCombat().getDefenders().iterator().next();
|
|
}
|
|
for (Permanent attacker : game.getBattlefield().getAllActivePermanents(filterCreatureForCombat, getId(), game)) {
|
|
if (game.getContinuousEffects().checkIfThereArePayCostToAttackBlockEffects(
|
|
new DeclareAttackerEvent(attackedDefender, attacker.getId(), attacker.getControllerId()), game)) {
|
|
continue;
|
|
}
|
|
// if attacker needs a specific defender to attack so select that one instead
|
|
if (game.getCombat().getCreaturesForcedToAttack().containsKey(attacker.getId())) {
|
|
Set<UUID> possibleDefenders = game.getCombat().getCreaturesForcedToAttack().get(attacker.getId());
|
|
if (!possibleDefenders.isEmpty() && !possibleDefenders.contains(attackedDefender)) {
|
|
declareAttacker(attacker.getId(), possibleDefenders.iterator().next(), game, false);
|
|
continue;
|
|
}
|
|
}
|
|
// attack selected default defender
|
|
declareAttacker(attacker.getId(), attackedDefender, game, false);
|
|
}
|
|
} else if (response.getInteger() != null) { // F-Key
|
|
if (checkIfAttackersValid(game)) {
|
|
return;
|
|
}
|
|
} else if (response.getBoolean() != null) { // ok button
|
|
if (checkIfAttackersValid(game)) {
|
|
return;
|
|
}
|
|
} else if (responseId != null) {
|
|
Permanent attacker = game.getPermanent(responseId);
|
|
if (attacker != null) {
|
|
if (filterCreatureForCombat.match(attacker, playerId, null, game)) {
|
|
selectDefender(game.getCombat().getDefenders(), attacker.getId(), game);
|
|
} else if (filterAttack.match(attacker, playerId, null, game) && game.getStack().isEmpty()) {
|
|
removeAttackerIfPossible(game, attacker);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private boolean checkIfAttackersValid(Game game) {
|
|
if (!game.getCombat().getCreaturesForcedToAttack().isEmpty()) {
|
|
if (!game.getCombat().getAttackers().containsAll(game.getCombat().getCreaturesForcedToAttack().keySet())) {
|
|
int forcedAttackers = 0;
|
|
StringBuilder sb = new StringBuilder();
|
|
for (UUID creatureId : game.getCombat().getCreaturesForcedToAttack().keySet()) {
|
|
boolean validForcedAttacker = false;
|
|
if (game.getCombat().getAttackers().contains(creatureId)) {
|
|
Set<UUID> possibleDefender = game.getCombat().getCreaturesForcedToAttack().get(creatureId);
|
|
if (possibleDefender.isEmpty()
|
|
|| possibleDefender.contains(game.getCombat().getDefenderId(creatureId))) {
|
|
validForcedAttacker = true;
|
|
}
|
|
}
|
|
if (validForcedAttacker) {
|
|
forcedAttackers++;
|
|
} else {
|
|
Permanent creature = game.getPermanent(creatureId);
|
|
if (creature != null) {
|
|
sb.append(creature.getIdName()).append(' ');
|
|
}
|
|
}
|
|
|
|
}
|
|
if (game.getCombat().getMaxAttackers() > forcedAttackers) {
|
|
int requireToAttack = Math.min(game.getCombat().getMaxAttackers() - forcedAttackers, game.getCombat().getCreaturesForcedToAttack().size() - forcedAttackers);
|
|
String message = (requireToAttack == 1 ? " more attacker that is " : " more attackers that are ")
|
|
+ "forced to attack.\nCreature"
|
|
+ (requireToAttack == 1 ? "" : "s") + " forced to attack: ";
|
|
game.informPlayer(this, sb.insert(0, message)
|
|
.insert(0, requireToAttack)
|
|
.insert(0, "You have to attack with ").toString());
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
// check if enough attackers are declared
|
|
// check if players have to be attacked
|
|
Set<UUID> playersToAttackIfAble = new HashSet<>();
|
|
|
|
// or if active player must attack with anything
|
|
boolean mustAttack = false;
|
|
|
|
for (Map.Entry<RequirementEffect, Set<Ability>> entry : game.getContinuousEffects().getApplicableRequirementEffects(null, true, game).entrySet()) {
|
|
RequirementEffect effect = entry.getKey();
|
|
for (Ability ability : entry.getValue()) {
|
|
UUID playerToAttack = effect.playerMustBeAttackedIfAble(ability, game);
|
|
if (playerToAttack != null) {
|
|
playersToAttackIfAble.add(playerToAttack);
|
|
}
|
|
if (effect.mustAttack(game)) {
|
|
mustAttack = true;
|
|
}
|
|
}
|
|
}
|
|
if (!playersToAttackIfAble.isEmpty()) {
|
|
Set<UUID> checkPlayersToAttackIfAble = new HashSet<>(playersToAttackIfAble);
|
|
for (CombatGroup combatGroup : game.getCombat().getGroups()) {
|
|
checkPlayersToAttackIfAble.remove(combatGroup.getDefendingPlayerId());
|
|
}
|
|
|
|
for (UUID forcedToAttackId : checkPlayersToAttackIfAble) {
|
|
Player forcedToAttack = game.getPlayer(forcedToAttackId);
|
|
|
|
for (Permanent attacker : game.getBattlefield().getAllActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, getId(), game)) {
|
|
|
|
if (game.getContinuousEffects().checkIfThereArePayCostToAttackBlockEffects(
|
|
new DeclareAttackerEvent(forcedToAttackId, attacker.getId(), attacker.getControllerId()), game)) {
|
|
continue;
|
|
}
|
|
// if attacker needs a specific defender to attack so select that one instead
|
|
if (game.getCombat().getCreaturesForcedToAttack().containsKey(attacker.getId())) {
|
|
Set<UUID> possibleDefenders = game.getCombat().getCreaturesForcedToAttack().get(attacker.getId());
|
|
if (!possibleDefenders.isEmpty() && !possibleDefenders.contains(forcedToAttackId)) {
|
|
continue;
|
|
}
|
|
}
|
|
UUID defendingPlayerId = game.getCombat().getDefendingPlayerId(attacker.getId(), game);
|
|
if (playersToAttackIfAble.contains(defendingPlayerId)) {
|
|
// already attacks other player taht has to be attacked
|
|
continue;
|
|
}
|
|
if (defendingPlayerId != null || attacker.canAttackInPrinciple(forcedToAttackId, game)) {
|
|
game.informPlayer(this, "You are forced to attack " + forcedToAttack.getName() + " or a controlled planeswalker e.g. with " + attacker.getIdName() + ".");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mustAttack && game.getCombat().getAttackers().isEmpty()) {
|
|
// no attackers, but required to attack with something -- check if anything can attack
|
|
for (Permanent attacker : game.getBattlefield().getAllActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, getId(), game)) {
|
|
if (attacker.canAttackInPrinciple(null, game)) {
|
|
game.informPlayer(this, "You are forced to attack with at least one creature, e.g. " + attacker.getIdName() + ".");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private void removeAttackerIfPossible(Game game, Permanent attacker) {
|
|
for (Map.Entry entry : game.getContinuousEffects().getApplicableRequirementEffects(attacker, false, game).entrySet()) {
|
|
RequirementEffect effect = (RequirementEffect) entry.getKey();
|
|
if (effect.mustAttack(game)) {
|
|
if (game.getCombat().getMaxAttackers() >= game.getCombat().getCreaturesForcedToAttack().size()
|
|
&& game.getCombat().getDefenders().size() == 1) {
|
|
return; // we can't change creatures forced to attack if only one possible defender exists and all forced creatures can attack
|
|
}
|
|
}
|
|
}
|
|
game.getCombat().removeAttacker(attacker.getId(), game);
|
|
}
|
|
|
|
/**
|
|
* Selects a defender for an attacker and adds the attacker to combat
|
|
*
|
|
* @param defenders - list of possible defender
|
|
* @param attackerId - UUID of attacker
|
|
* @param game
|
|
* @return
|
|
*/
|
|
protected boolean selectDefender(Set<UUID> defenders, UUID attackerId, Game game) {
|
|
boolean forcedToAttack = false;
|
|
Set<UUID> possibleDefender = game.getCombat().getCreaturesForcedToAttack().get(attackerId);
|
|
if (possibleDefender != null) {
|
|
forcedToAttack = true;
|
|
}
|
|
if (possibleDefender == null
|
|
|| possibleDefender.isEmpty()) {
|
|
possibleDefender = defenders;
|
|
}
|
|
if (possibleDefender.size() == 1) {
|
|
declareAttacker(attackerId, possibleDefender.iterator().next(), game, true);
|
|
return true;
|
|
} else {
|
|
TargetDefender target = new TargetDefender(possibleDefender);
|
|
if (forcedToAttack) {
|
|
StringBuilder sb = new StringBuilder(target.getTargetName());
|
|
Permanent attacker = game.getPermanent(attackerId);
|
|
if (attacker != null) {
|
|
sb.append(" (").append(attacker.getName()).append(')');
|
|
target.withTargetName(sb.toString());
|
|
}
|
|
}
|
|
if (chooseTarget(Outcome.Damage, target, null, game)) {
|
|
UUID defenderId = getFixedResponseUUID(game);
|
|
declareAttacker(attackerId, defenderId, game, true);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected UUID selectDefenderForAllAttack(Set<UUID> defenders, Game game) {
|
|
TargetDefender target = new TargetDefender(defenders);
|
|
if (chooseTarget(Outcome.Damage, target, null, game)) {
|
|
return getFixedResponseUUID(game);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public void selectBlockers(Ability source, Game game, UUID defendingPlayerId) {
|
|
if (!canCallFeedback(game)) {
|
|
return;
|
|
}
|
|
|
|
FilterCreatureForCombatBlock filter = filterCreatureForCombatBlock.copy();
|
|
filter.add(new ControllerIdPredicate(defendingPlayerId));
|
|
|
|
// stop skip on any/zero permanents available
|
|
int possibleBlockersCount = game.getBattlefield().count(filter, playerId, source, game);
|
|
boolean canStopOnAny = possibleBlockersCount != 0 && getControllingPlayersUserData(game).getUserSkipPrioritySteps().isStopOnDeclareBlockersWithAnyPermanents();
|
|
|
|
// skip declare blocker step
|
|
// as opposed to declare attacker - it can be skipped by ANY skip button TODO: make same for declare attackers and rework skip buttons (normal and forced)
|
|
boolean skipButtonActivated = passedAllTurns
|
|
|| passedUntilEndStepBeforeMyTurn
|
|
|| passedTurn
|
|
|| passedUntilEndOfTurn
|
|
|| passedUntilNextMain;
|
|
if (skipButtonActivated && !canStopOnAny) {
|
|
return;
|
|
}
|
|
// Skip prompt to select blockers if player has none
|
|
if (possibleBlockersCount == 0) return;
|
|
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
Map<String, Serializable> options = new HashMap<>();
|
|
java.util.List<UUID> possibleBlockers = game.getBattlefield().getActivePermanents(filter, playerId, game).stream()
|
|
.map(p -> p.getId())
|
|
.collect(Collectors.toList());
|
|
options.put(Constants.Option.POSSIBLE_BLOCKERS, (Serializable) possibleBlockers);
|
|
game.fireSelectEvent(playerId, "Select blockers", options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (response.getBoolean() != null) {
|
|
return;
|
|
} else if (response.getInteger() != null) {
|
|
return;
|
|
} else if (responseId != null) {
|
|
Permanent blocker = game.getPermanent(responseId);
|
|
if (blocker != null) {
|
|
boolean removeBlocker = false;
|
|
// does not block yet and can block or can block more attackers
|
|
if (filter.match(blocker, playerId, source, game)) {
|
|
selectCombatGroup(defendingPlayerId, blocker.getId(), game);
|
|
} else if (filterBlock.match(blocker, playerId, source, game)
|
|
&& game.getStack().isEmpty()) {
|
|
removeBlocker = true;
|
|
}
|
|
|
|
if (removeBlocker) {
|
|
game.getCombat().removeBlocker(blocker.getId(), game);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected void selectCombatGroup(UUID defenderId, UUID blockerId, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return;
|
|
}
|
|
TargetAttackingCreature target = new TargetAttackingCreature();
|
|
|
|
// TODO: add canRespond cycle?
|
|
if (!canRespond()) {
|
|
return;
|
|
}
|
|
|
|
UUID responseId = null;
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
// possible attackers to block
|
|
Set<UUID> attackers = target.possibleTargets(playerId, null, game);
|
|
Permanent blocker = game.getPermanent(blockerId);
|
|
Set<UUID> possibleTargets = new HashSet<>();
|
|
for (UUID attackerId : attackers) {
|
|
CombatGroup group = game.getCombat().findGroup(attackerId);
|
|
if (group != null && blocker != null && group.canBlock(blocker, game)) {
|
|
possibleTargets.add(attackerId);
|
|
}
|
|
}
|
|
if (possibleTargets.size() == 1) {
|
|
responseId = possibleTargets.stream().iterator().next();
|
|
} else {
|
|
game.fireSelectTargetEvent(playerId, new MessageToClient("Select attacker to block", getRelatedObjectName(blockerId, game)),
|
|
possibleTargets, false, getOptions(target, null));
|
|
waitForResponse(game);
|
|
}
|
|
}
|
|
|
|
if (responseId == null) {
|
|
responseId = getFixedResponseUUID(game);
|
|
}
|
|
|
|
if (response.getBoolean() != null) {
|
|
// do nothing
|
|
} else if (responseId != null) {
|
|
CombatGroup group = game.getCombat().findGroup(responseId);
|
|
if (group != null) {
|
|
// check if already blocked, if not add
|
|
if (!group.getBlockers().contains(blockerId)) {
|
|
declareBlocker(defenderId, blockerId, responseId, game);
|
|
} else { // else remove from block
|
|
game.getCombat().removeBlockerGromGroup(blockerId, group, game);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public int getAmount(int min, int max, String message, Ability source, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return min;
|
|
}
|
|
|
|
// fast calc on nothing to choose
|
|
if (min >= max) {
|
|
return min;
|
|
}
|
|
|
|
int xValue = min;
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireGetAmountEvent(playerId, message + CardUtil.getSourceLogName(game, source), min, max);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
if (response.getInteger() == null) {
|
|
continue;
|
|
}
|
|
|
|
xValue = response.getInteger();
|
|
if (xValue < min || xValue > max) {
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return xValue;
|
|
}
|
|
|
|
@Override
|
|
public List<Integer> getMultiAmountWithIndividualConstraints(
|
|
Outcome outcome,
|
|
List<MultiAmountMessage> messages,
|
|
int totalMin,
|
|
int totalMax,
|
|
MultiAmountType type,
|
|
Game game
|
|
) {
|
|
int needCount = messages.size();
|
|
List<Integer> defaultList = MultiAmountType.prepareDefaultValues(messages, totalMin, totalMax);
|
|
if (needCount == 0 || (needCount == 1 && totalMin == totalMax)
|
|
|| messages.stream().map(m -> m.min == m.max).reduce(true, Boolean::logicalAnd)) {
|
|
// nothing to choose
|
|
return defaultList;
|
|
}
|
|
|
|
if (!canCallFeedback(game)) {
|
|
return defaultList;
|
|
}
|
|
|
|
List<Integer> answer = null;
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
Map<String, Serializable> options = new HashMap<>(2);
|
|
options.put("title", type.getTitle());
|
|
options.put("header", type.getHeader());
|
|
if (type.isCanCancel()) {
|
|
options.put("canCancel", true);
|
|
}
|
|
game.fireGetMultiAmountEvent(playerId, messages, totalMin, totalMax, options);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
// waiting correct values only
|
|
if (response.getString() != null) {
|
|
answer = MultiAmountType.parseAnswer(response.getString(), messages, totalMin, totalMax, false);
|
|
if (MultiAmountType.isGoodValues(answer, messages, totalMin, totalMax)) {
|
|
break;
|
|
} else {
|
|
// it's not normal: can be cheater or a wrong GUI checks
|
|
answer = null;
|
|
logger.error(String.format("GUI return wrong MultiAmountType values: %d %d %d - %s", needCount, totalMin, totalMax, response.getString()));
|
|
game.informPlayer(this, "Error, you must enter correct values.");
|
|
}
|
|
} else if (type.isCanCancel() && response.getBoolean() != null) {
|
|
answer = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (answer != null) {
|
|
return answer;
|
|
} else if (type.isCanCancel()) {
|
|
// cancel
|
|
return null;
|
|
} else {
|
|
// something wrong, e.g. player disconnected
|
|
return defaultList;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void sideboard(Match match, Deck deck) {
|
|
match.fireSideboardEvent(playerId, deck);
|
|
}
|
|
|
|
@Override
|
|
public void construct(Tournament tournament, Deck deck) {
|
|
tournament.fireConstructEvent(playerId);
|
|
}
|
|
|
|
@Override
|
|
public void pickCard(java.util.List<Card> cards, Deck deck, Draft draft) {
|
|
draft.firePickCardEvent(playerId);
|
|
}
|
|
|
|
/**
|
|
* Activate special action (normal or mana)
|
|
*
|
|
* @param game
|
|
* @param unpaidForManaAction - set unpaid for mana actions like convoke
|
|
*/
|
|
protected void activateSpecialAction(Game game, ManaCost unpaidForManaAction) {
|
|
if (!canCallFeedback(game)) {
|
|
return;
|
|
}
|
|
|
|
if (!canRespond()) {
|
|
return;
|
|
}
|
|
|
|
Map<UUID, SpecialAction> specialActions = game.getState().getSpecialActions().getControlledBy(playerId, unpaidForManaAction != null);
|
|
if (!specialActions.isEmpty()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireGetChoiceEvent(playerId, name, null, new ArrayList<>(specialActions.values()));
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
SpecialAction specialAction = specialActions.getOrDefault(responseId, null);
|
|
if (specialAction != null) {
|
|
activateAbility(specialAction, game);
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean activateAbility(ActivatedAbility ability, Game game) {
|
|
getManaPool().setStock(); // needed for the "mana already in the pool has to be used manually" option
|
|
return super.activateAbility(ability, game);
|
|
}
|
|
|
|
protected void activateAbility(Map<UUID, ? extends ActivatedAbility> abilities, MageObject object, Game game) {
|
|
if (!canCallFeedback(game)) {
|
|
return;
|
|
}
|
|
|
|
if (!canRespond()) {
|
|
return;
|
|
}
|
|
|
|
if (abilities.size() == 1
|
|
&& suppressAbilityPicker(abilities.values().iterator().next(), game)) {
|
|
ActivatedAbility ability = abilities.values().iterator().next();
|
|
if (!ability.getTargets().isEmpty()
|
|
|| !(ability.getCosts().size() == 1
|
|
&& ability.getCosts().get(0) instanceof SacrificeSourceCost)
|
|
|| !(ability.getCosts().size() == 2
|
|
&& ability.getCosts().get(0) instanceof TapSourceCost
|
|
&& ability.getCosts().get(0) instanceof SacrificeSourceCost)) {
|
|
activateAbility(ability, game);
|
|
return;
|
|
}
|
|
}
|
|
if (userData.isUseFirstManaAbility() && object instanceof Permanent && object.isLand(game)) {
|
|
ActivatedAbility ability = abilities.values().iterator().next();
|
|
if (ability.isActivatedAbility() && ability.isManaAbility()) {
|
|
activateAbility(ability, game);
|
|
return;
|
|
}
|
|
}
|
|
|
|
String message = "Choose spell or ability to play";
|
|
if (object != null) {
|
|
message = message + "<br>" + object.getLogName();
|
|
}
|
|
|
|
// it's inner method, parent code already uses while and canRespond cycle,
|
|
// so can request one time here
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireGetChoiceEvent(playerId, message, object, new ArrayList<>(abilities.values()));
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (responseId != null) {
|
|
if (abilities.containsKey(responseId)) {
|
|
activateAbility(abilities.get(responseId), game);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide ability picker dialog on one available ability to activate
|
|
*/
|
|
private boolean suppressAbilityPicker(ActivatedAbility ability, Game game) {
|
|
// TODO: is it bugged on mana payment + under control?
|
|
// (if player under control then priority player must use own settings, not controlling)
|
|
// user activated an ability picker in preferences
|
|
|
|
// force to show ability picker for double faces cards in hand/commander/exile and other zones
|
|
Card mainCard = game.getCard(CardUtil.getMainCardId(game, ability.getSourceId()));
|
|
if (mainCard != null && !Zone.BATTLEFIELD.equals(game.getState().getZone(mainCard.getId()))) {
|
|
if (mainCard instanceof SplitCard
|
|
|| mainCard instanceof CardWithSpellOption
|
|
|| mainCard instanceof ModalDoubleFacedCard) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// hide on land play
|
|
if (ability instanceof PlayLandAbility) {
|
|
return true;
|
|
}
|
|
|
|
// hide on alternative cost activated
|
|
if (!getCastSourceIdWithAlternateMana().getOrDefault(ability.getSourceId(), Collections.emptySet()).contains(MageIdentifier.Default)
|
|
&& ability.getManaCostsToPay().manaValue() > 0) {
|
|
return true;
|
|
}
|
|
|
|
// hide on mana activate and show all other
|
|
return ability.isManaActivatedAbility();
|
|
}
|
|
|
|
@Override
|
|
public SpellAbility chooseAbilityForCast(Card card, Game game, boolean noMana) {
|
|
if (!canCallFeedback(game)) {
|
|
return null;
|
|
}
|
|
|
|
// TODO: add canRespond cycle?
|
|
if (!canRespond()) {
|
|
return null;
|
|
}
|
|
|
|
MageObject object = game.getObject(card.getId()); // must be object to find real abilities (example: commander)
|
|
if (object != null) {
|
|
String message = "Choose ability to cast" + (noMana ? " for FREE" : "") + "<br>" + object.getLogName();
|
|
Map<UUID, SpellAbility> useableAbilities = PlayerImpl.getCastableSpellAbilities(game, playerId, object, game.getState().getZone(object.getId()), noMana);
|
|
if (useableAbilities != null
|
|
&& useableAbilities.size() == 1) {
|
|
return useableAbilities.values().iterator().next();
|
|
} else if (useableAbilities != null
|
|
&& !useableAbilities.isEmpty()) {
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireGetChoiceEvent(playerId, message, object, new ArrayList<>(useableAbilities.values()));
|
|
}
|
|
waitForResponse(game);
|
|
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (responseId != null) {
|
|
if (useableAbilities.containsKey(responseId)) {
|
|
return useableAbilities.get(responseId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// default ability (example: on disconnect or cancel)
|
|
return card.getSpellAbility();
|
|
}
|
|
|
|
@Override
|
|
public ActivatedAbility chooseLandOrSpellAbility(Card card, Game game, boolean noMana) {
|
|
if (!canCallFeedback(game)) {
|
|
return null;
|
|
}
|
|
|
|
// TODO: add canRespond cycle?
|
|
if (!canRespond()) {
|
|
return null;
|
|
}
|
|
|
|
MageObject object = game.getObject(card.getId()); // must be object to find real abilities (example: commander)
|
|
if (object != null) {
|
|
LinkedHashMap<UUID, ActivatedAbility> useableAbilities = new LinkedHashMap<>(PlayerImpl.getCastableSpellAbilities(game, playerId, object, game.getState().getZone(object.getId()), noMana));
|
|
|
|
if (canPlayLand() && isActivePlayer(game)) {
|
|
for (Ability ability : card.getAbilities(game)) {
|
|
if (ability instanceof PlayLandAbility) {
|
|
useableAbilities.put(ability.getId(), (PlayLandAbility) ability);
|
|
}
|
|
}
|
|
}
|
|
|
|
switch (useableAbilities.size()) {
|
|
case 0:
|
|
return card.getSpellAbility();
|
|
case 1:
|
|
return useableAbilities.values().iterator().next();
|
|
default:
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
String message = "Choose spell or ability to play" + (noMana ? " for FREE" : "") + "<br>" + object.getLogName();
|
|
game.fireGetChoiceEvent(playerId, message, object, new ArrayList<>(useableAbilities.values()));
|
|
}
|
|
waitForResponse(game);
|
|
ActivatedAbility response = useableAbilities.get(getFixedResponseUUID(game));
|
|
if (response != null) {
|
|
return response;
|
|
}
|
|
}
|
|
}
|
|
|
|
// default ability (example: on disconnect or cancel)
|
|
return card.getSpellAbility();
|
|
}
|
|
|
|
@Override
|
|
public Mode chooseMode(Modes modes, Ability source, Game game) {
|
|
// choose mode to activate
|
|
if (!canCallFeedback(game)) {
|
|
return null;
|
|
}
|
|
|
|
if (modes.size() == 0) {
|
|
return null;
|
|
}
|
|
|
|
if (modes.size() == 1) {
|
|
return modes.getMode();
|
|
}
|
|
|
|
boolean done = false;
|
|
while (!done && canRespond()) {
|
|
// prepare modes list
|
|
MageObject obj = game.getObject(source);
|
|
Map<UUID, String> modeMap = new LinkedHashMap<>();
|
|
int modeIndex = 0;
|
|
AvailableModes:
|
|
for (Mode mode : modes.getAvailableModes(source, game)) {
|
|
modeIndex++;
|
|
int timesSelected = modes.getSelectedStats(mode.getId());
|
|
for (UUID selectedModeId : modes.getSelectedModes()) {
|
|
Mode selectedMode = modes.get(selectedModeId);
|
|
if (mode.getId().equals(selectedMode.getId())) {
|
|
// mode selected
|
|
if (modes.isMayChooseSameModeMoreThanOnce()) {
|
|
// can select again
|
|
} else {
|
|
// hide mode from dialog
|
|
continue AvailableModes; // TODO: test 2x cheat here
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mode.getTargets().canChoose(source.getControllerId(), source, game)) { // and needed targets have to be available
|
|
String modeText = mode.getEffects().getText(mode);
|
|
if (obj != null) {
|
|
modeText = modeText.replace("{this}", obj.getName());
|
|
}
|
|
if (modes.isMayChooseSameModeMoreThanOnce()) {
|
|
if (timesSelected > 0) {
|
|
modeText = "(selected " + timesSelected + "x) " + modeText;
|
|
}
|
|
}
|
|
if (!modeText.isEmpty()) {
|
|
modeText = Character.toUpperCase(modeText.charAt(0)) + modeText.substring(1);
|
|
}
|
|
StringBuilder sb = new StringBuilder();
|
|
if (mode.getPawPrintValue() > 0) {
|
|
for (int i = 0; i < mode.getPawPrintValue(); ++i) {
|
|
sb.append("{P}");
|
|
}
|
|
sb.append(": ");
|
|
} else {
|
|
sb.append(modeIndex).append(". ");
|
|
}
|
|
modeMap.put(mode.getId(), sb.append(modeText).toString());
|
|
}
|
|
}
|
|
|
|
// done button for "for up" choices only
|
|
boolean canEndChoice = (modes.getSelectedModes().size() >= modes.getMinModes() && modes.getMaxPawPrints() == 0) ||
|
|
(modes.getSelectedPawPrints() >= modes.getMaxPawPrints() && modes.getMaxPawPrints() > 0) ||
|
|
modes.isMayChooseNone();
|
|
if (canEndChoice) {
|
|
modeMap.put(Modes.CHOOSE_OPTION_DONE_ID, "Done");
|
|
}
|
|
modeMap.put(Modes.CHOOSE_OPTION_CANCEL_ID, "Cancel");
|
|
|
|
// prepare dialog
|
|
String message;
|
|
if (modes.getMaxPawPrints() == 0) {
|
|
message = "Choose mode (selected " + modes.getSelectedModes().size() + " of " + modes.getMaxModes(game, source)
|
|
+ ", min " + modes.getMinModes() + ")";
|
|
} else {
|
|
message = "Choose mode (selected " + modes.getSelectedPawPrints() + " of " + modes.getMaxPawPrints()
|
|
+ " {P})";
|
|
}
|
|
|
|
if (obj != null) {
|
|
message = message + "<br>" + obj.getLogName();
|
|
}
|
|
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireGetModeEvent(playerId, message, modeMap);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
// process choice
|
|
UUID responseId = getFixedResponseUUID(game);
|
|
if (responseId != null) {
|
|
for (Mode mode : modes.getAvailableModes(source, game)) {
|
|
if (mode.getId().equals(responseId)) {
|
|
// TODO: add checks on 2x selects (cheaters can rewrite client side code and select same mode multiple times)
|
|
// reason: wrong setup eachModeMoreThanOnce and eachModeOnlyOnce in many cards
|
|
return mode;
|
|
}
|
|
}
|
|
|
|
// end choice by done option in ability pickup dialog
|
|
if (canEndChoice && Modes.CHOOSE_OPTION_DONE_ID.equals(responseId)) {
|
|
done = true;
|
|
}
|
|
|
|
// cancel choice (remove all selections)
|
|
if (Modes.CHOOSE_OPTION_CANCEL_ID.equals(responseId)) {
|
|
modes.clearSelectedModes();
|
|
}
|
|
} else if (canEndChoice) {
|
|
// end choice by done button in feedback panel
|
|
// disable after done option implemented
|
|
// done = true;
|
|
}
|
|
|
|
// triggered abilities can't be skipped by cancel or wrong answer
|
|
if (!source.isTriggeredAbility()) {
|
|
done = true;
|
|
}
|
|
}
|
|
|
|
// user disconnected, press cancel, press done or something else
|
|
return null;
|
|
}
|
|
|
|
@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 false;
|
|
}
|
|
|
|
while (canRespond()) {
|
|
prepareForResponse(game);
|
|
if (!isExecutingMacro()) {
|
|
game.fireChoosePileEvent(playerId, message, pile1, pile2);
|
|
}
|
|
waitForResponse(game);
|
|
|
|
if (response.getBoolean() != null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (response.getBoolean() != null) {
|
|
return response.getBoolean();
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setResponseString(String responseString) {
|
|
if (!waitResponseOpen()) {
|
|
return;
|
|
}
|
|
synchronized (response) {
|
|
response.setString(responseString);
|
|
response.notifyAll();
|
|
logger.debug("Got response string from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setResponseManaType(UUID manaTypePlayerId, ManaType manaType) {
|
|
if (!waitResponseOpen()) {
|
|
return;
|
|
}
|
|
synchronized (response) {
|
|
response.setManaType(manaType);
|
|
response.setResponseManaPlayerId(manaTypePlayerId);
|
|
response.notifyAll();
|
|
logger.debug("Got response mana type from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setResponseUUID(UUID responseUUID) {
|
|
if (!waitResponseOpen()) {
|
|
return;
|
|
}
|
|
synchronized (response) {
|
|
response.setUUID(responseUUID);
|
|
response.notifyAll();
|
|
logger.debug("Got response UUID from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setResponseBoolean(Boolean responseBoolean) {
|
|
if (!waitResponseOpen()) {
|
|
return;
|
|
}
|
|
synchronized (response) {
|
|
response.setBoolean(responseBoolean);
|
|
response.notifyAll();
|
|
logger.debug("Got response boolean from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setResponseInteger(Integer responseInteger) {
|
|
if (!waitResponseOpen()) {
|
|
return;
|
|
}
|
|
synchronized (response) {
|
|
response.setInteger(responseInteger);
|
|
response.notifyAll();
|
|
logger.debug("Got response integer from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void abort() {
|
|
// abort must cancel any response and stop waiting immediately
|
|
abort = true;
|
|
synchronized (response) {
|
|
response.notifyAll();
|
|
logger.debug("Got cancel action from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void signalPlayerConcede(boolean stopCurrentChooseDialog) {
|
|
// waitResponseOpen(); // concede is async event, will be processed on first priority
|
|
|
|
// may be executed in CALL, HEALTH, GAME and other threads
|
|
// so make sure another player can't break/stop currently choosing player
|
|
|
|
synchronized (response) {
|
|
response.setAsyncWantConcede(); // tell game that it must check conceding players
|
|
if (stopCurrentChooseDialog) {
|
|
response.notifyAll(); // will force to stop a current waiting dialog (so game can continue)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void signalPlayerCheat() {
|
|
// waitResponseOpen(); // cheat is async event, will be processed on first player's priority
|
|
synchronized (response) {
|
|
response.setAsyncWantCheat();
|
|
response.notifyAll();
|
|
logger.debug("Set cheat for waiting player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void skip() {
|
|
// waitResponseOpen(); //skip is direct event, no need to wait it
|
|
// TODO: can be bugged and must be reworked, see wantConcede as example?!
|
|
synchronized (response) {
|
|
response.setInteger(0);
|
|
response.notifyAll();
|
|
logger.debug("Got skip action from player: " + getId());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public HumanPlayer copy() {
|
|
return new HumanPlayer(this);
|
|
}
|
|
|
|
@Override
|
|
public void sendPlayerAction(PlayerAction playerAction, Game game, Object data) {
|
|
switch (playerAction) {
|
|
case RESET_AUTO_SELECT_REPLACEMENT_EFFECTS:
|
|
autoSelectReplacementEffects.clear();
|
|
break;
|
|
case TRIGGER_AUTO_ORDER_ABILITY_FIRST:
|
|
case TRIGGER_AUTO_ORDER_ABILITY_LAST:
|
|
case TRIGGER_AUTO_ORDER_NAME_FIRST:
|
|
case TRIGGER_AUTO_ORDER_NAME_LAST:
|
|
case TRIGGER_AUTO_ORDER_RESET_ALL:
|
|
setTriggerAutoOrder(playerAction, game, data);
|
|
break;
|
|
case REQUEST_AUTO_ANSWER_ID_NO:
|
|
case REQUEST_AUTO_ANSWER_ID_YES:
|
|
case REQUEST_AUTO_ANSWER_TEXT_NO:
|
|
case REQUEST_AUTO_ANSWER_TEXT_YES:
|
|
case REQUEST_AUTO_ANSWER_RESET_ALL:
|
|
setRequestAutoAnswer(playerAction, game, data);
|
|
break;
|
|
case HOLD_PRIORITY:
|
|
holdingPriority = true;
|
|
break;
|
|
case UNHOLD_PRIORITY:
|
|
holdingPriority = false;
|
|
break;
|
|
case TOGGLE_RECORD_MACRO:
|
|
if (true) {
|
|
return; // TODO: macro unsupported in current version
|
|
}
|
|
if (recordingMacro) {
|
|
logger.debug("Finished Recording Macro");
|
|
activatingMacro = true;
|
|
recordingMacro = false;
|
|
actionIterations = announceRepetitions(game);
|
|
try {
|
|
synchronized (actionQueue) {
|
|
actionQueue.wait();
|
|
}
|
|
} catch (InterruptedException ex) {
|
|
} finally {
|
|
activatingMacro = false;
|
|
}
|
|
} else {
|
|
logger.debug("Starting Recording Macro");
|
|
resetPlayerPassedActions();
|
|
recordingMacro = true;
|
|
actionIterations = 0;
|
|
actionQueueSaved.clear();
|
|
actionQueue.clear();
|
|
}
|
|
break;
|
|
case PASS_PRIORITY_UNTIL_STACK_RESOLVED:
|
|
// stop recording only, real stack processing in PlayerImpl
|
|
if (recordingMacro) {
|
|
logger.debug("Adding a resolveStack");
|
|
PlayerResponse tResponse = new PlayerResponse();
|
|
tResponse.setString("resolveStack");
|
|
actionQueueSaved.add(tResponse);
|
|
}
|
|
super.sendPlayerAction(playerAction, game, data);
|
|
break;
|
|
default:
|
|
super.sendPlayerAction(playerAction, game, data);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void setRequestAutoAnswer(PlayerAction playerAction, Game game, Object data) {
|
|
if (playerAction == REQUEST_AUTO_ANSWER_RESET_ALL) {
|
|
requestAutoAnswerId.clear();
|
|
requestAutoAnswerText.clear();
|
|
return;
|
|
}
|
|
if (data instanceof String) {
|
|
String key = (String) data;
|
|
switch (playerAction) {
|
|
case REQUEST_AUTO_ANSWER_ID_NO:
|
|
requestAutoAnswerId.put(key, false);
|
|
break;
|
|
case REQUEST_AUTO_ANSWER_TEXT_NO:
|
|
requestAutoAnswerText.put(key, false);
|
|
break;
|
|
case REQUEST_AUTO_ANSWER_ID_YES:
|
|
requestAutoAnswerId.put(key, true);
|
|
break;
|
|
case REQUEST_AUTO_ANSWER_TEXT_YES:
|
|
requestAutoAnswerText.put(key, true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GUI related, remember choices for choose trigger dialog
|
|
*
|
|
* @param playerAction
|
|
* @param game
|
|
* @param data
|
|
*/
|
|
private void setTriggerAutoOrder(PlayerAction playerAction, Game game, Object data) {
|
|
if (playerAction == TRIGGER_AUTO_ORDER_RESET_ALL) {
|
|
triggerAutoOrderAbilityFirst.clear();
|
|
triggerAutoOrderAbilityLast.clear();
|
|
triggerAutoOrderNameFirst.clear();
|
|
triggerAutoOrderNameLast.clear();
|
|
return;
|
|
}
|
|
|
|
if (data instanceof UUID) {
|
|
// remember by id
|
|
UUID abilityId = (UUID) data;
|
|
UUID originalId = null;
|
|
for (TriggeredAbility ability : game.getState().getTriggered(getId())) {
|
|
if (ability.getId().equals(abilityId)) {
|
|
originalId = ability.getOriginalId();
|
|
break;
|
|
}
|
|
}
|
|
if (originalId != null) {
|
|
switch (playerAction) {
|
|
case TRIGGER_AUTO_ORDER_ABILITY_FIRST:
|
|
triggerAutoOrderAbilityFirst.add(originalId);
|
|
break;
|
|
case TRIGGER_AUTO_ORDER_ABILITY_LAST:
|
|
triggerAutoOrderAbilityLast.add(originalId);
|
|
break;
|
|
}
|
|
}
|
|
} else if (data instanceof String) {
|
|
// remember by name
|
|
String abilityName = (String) data;
|
|
if (abilityName.contains("{this}")) {
|
|
throw new IllegalArgumentException("Wrong code usage. Remembering trigger must contains full rules name without {this}.");
|
|
}
|
|
|
|
switch (playerAction) {
|
|
case TRIGGER_AUTO_ORDER_NAME_FIRST:
|
|
triggerAutoOrderNameFirst.add(abilityName);
|
|
break;
|
|
case TRIGGER_AUTO_ORDER_NAME_LAST:
|
|
triggerAutoOrderNameLast.add(abilityName);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
protected boolean passWithManaPoolCheck(Game game) {
|
|
if (userData.confirmEmptyManaPool()
|
|
&& game.getStack().isEmpty() && getManaPool().count() > 0 && getManaPool().canLostManaOnEmpty()) {
|
|
String message = GameLog.getPlayerConfirmColoredText("You still have mana in your mana pool and it will be lost. Pass anyway?");
|
|
if (!chooseUse(Outcome.Detriment, message, null, game)) {
|
|
sendPlayerAction(PlayerAction.PASS_PRIORITY_CANCEL_ALL_ACTIONS, game, null);
|
|
return false;
|
|
}
|
|
}
|
|
pass(game);
|
|
return true;
|
|
}
|
|
|
|
private boolean gameInCheckPlayableState(Game game) {
|
|
return gameInCheckPlayableState(game, false);
|
|
}
|
|
|
|
private boolean gameInCheckPlayableState(Game game, boolean ignoreWarning) {
|
|
if (game.inCheckPlayableState()) {
|
|
if (!ignoreWarning) {
|
|
logger.warn(String.format("Current stack: %d - %s",
|
|
game.getStack().size(),
|
|
game.getStack().stream().map(Object::toString).collect(Collectors.joining(", "))
|
|
));
|
|
logger.warn("Player interaction in checkPlayableState", new Throwable());
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public Player prepareControllableProxy(Player playerUnderControl) {
|
|
// make fake player, e.g. transform computer player to human player for choose dialogs under control
|
|
HumanPlayer fakePlayer = new HumanPlayer((PlayerImpl) playerUnderControl, this.response);
|
|
if (!fakePlayer.getTurnControlledBy().equals(this.getId())) {
|
|
throw new IllegalArgumentException("Wrong code usage: controllable proxy must be controlled by " + this.getName());
|
|
}
|
|
return fakePlayer;
|
|
}
|
|
}
|