Turn under control reworked:

- game: added support for human games (cards like Emrakul, the Promised End, #12878);
 - game: added support of 720.1. to reset control in the turn beginning instead cleanup step (related to #12115);
 - game: added game logs for priorities in cleanup step;
 - game: fixed game freezes and wrong skip settings usages (related to #12878);
 - gui: added playable and choose-able marks for controlling player's cards and permanents, including switched hands;
 - gui: added controlling player name in all choice dialogs;
 - info: control of computer players is it not yet supported;
This commit is contained in:
Oleg Agafonov 2025-01-07 12:26:30 +04:00
parent 75d241d541
commit c076f4925f
17 changed files with 177 additions and 140 deletions

View file

@ -1,42 +0,0 @@
package mage.abilities.common.delayed;
import mage.abilities.DelayedTriggeredAbility;
import mage.abilities.effects.Effect;
import mage.game.Game;
import mage.game.events.GameEvent;
/**
* @author nantuko
*/
public class AtTheEndOfTurnStepPostDelayedTriggeredAbility extends DelayedTriggeredAbility {
public AtTheEndOfTurnStepPostDelayedTriggeredAbility(Effect effect) {
this(effect, false);
}
public AtTheEndOfTurnStepPostDelayedTriggeredAbility(Effect effect, boolean usesStack) {
super(effect);
this.usesStack = usesStack;
setTriggerPhrase("At end of turn ");
}
public AtTheEndOfTurnStepPostDelayedTriggeredAbility(AtTheEndOfTurnStepPostDelayedTriggeredAbility ability) {
super(ability);
}
@Override
public AtTheEndOfTurnStepPostDelayedTriggeredAbility copy() {
return new AtTheEndOfTurnStepPostDelayedTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.END_TURN_STEP_POST;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
return true;
}
}

View file

@ -550,6 +550,12 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
@Deprecated // TODO: must research usage and remove it from all non engine code (example: Bestow ability, ProcessActions must be used instead)
boolean checkStateAndTriggered();
/**
* Play priority by all players
*
* @param activePlayerId starting priority player
* @param resuming false to reset passed priority and ask it again
*/
void playPriority(UUID activePlayerId, boolean resuming);
void resetControlAfterSpellResolve(UUID topId);

View file

@ -2987,21 +2987,35 @@ public abstract class GameImpl implements Game {
}
String message;
if (this.canPlaySorcery(playerId)) {
message = "Play spells and abilities.";
message = "Play spells and abilities";
} else {
message = "Play instants and activated abilities.";
message = "Play instants and activated abilities";
}
playerQueryEventSource.select(playerId, message);
message += getControllingPlayerHint(playerId);
Player player = this.getPlayer(playerId);
playerQueryEventSource.select(player.getTurnControlledBy(), message);
getState().clearLookedAt();
getState().clearRevealed();
}
private String getControllingPlayerHint(UUID playerId) {
Player player = this.getPlayer(playerId);
Player controllingPlayer = this.getPlayer(player.getTurnControlledBy());
if (player != controllingPlayer) {
return " (as " + player.getLogName() + ")";
} else {
return "";
}
}
@Override
public synchronized void fireSelectEvent(UUID playerId, String message) {
if (simulation) {
return;
}
playerQueryEventSource.select(playerId, message);
playerQueryEventSource.select(playerId, message + getControllingPlayerHint(playerId));
}
@Override
@ -3009,7 +3023,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.select(playerId, message, options);
playerQueryEventSource.select(playerId, message + getControllingPlayerHint(playerId), options);
}
@Override
@ -3017,7 +3031,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.playMana(playerId, message, options);
playerQueryEventSource.playMana(playerId, message + getControllingPlayerHint(playerId), options);
}
@Override
@ -3025,7 +3039,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.playXMana(playerId, message);
playerQueryEventSource.playXMana(playerId, message + getControllingPlayerHint(playerId));
}
@Override
@ -3038,7 +3052,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.ask(playerId, message.getMessage(), source, addMessageToOptions(message, options));
playerQueryEventSource.ask(playerId, message.getMessage() + getControllingPlayerHint(playerId), source, addMessageToOptions(message, options));
}
@Override
@ -3050,7 +3064,7 @@ public abstract class GameImpl implements Game {
if (object != null) {
objectName = object.getName();
}
playerQueryEventSource.chooseAbility(playerId, message, objectName, choices);
playerQueryEventSource.chooseAbility(playerId, message + getControllingPlayerHint(playerId), objectName, choices);
}
@Override
@ -3058,7 +3072,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.chooseMode(playerId, message, modes);
playerQueryEventSource.chooseMode(playerId, message + getControllingPlayerHint(playerId), modes);
}
@Override
@ -3066,7 +3080,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.target(playerId, message.getMessage(), targets, required, addMessageToOptions(message, options));
playerQueryEventSource.target(playerId, message.getMessage() + getControllingPlayerHint(playerId), targets, required, addMessageToOptions(message, options));
}
@Override
@ -3074,7 +3088,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.target(playerId, message.getMessage(), cards, required, addMessageToOptions(message, options));
playerQueryEventSource.target(playerId, message.getMessage() + getControllingPlayerHint(playerId), cards, required, addMessageToOptions(message, options));
}
/**
@ -3087,7 +3101,7 @@ public abstract class GameImpl implements Game {
*/
@Override
public void fireSelectTargetTriggeredAbilityEvent(UUID playerId, String message, List<TriggeredAbility> abilities) {
playerQueryEventSource.target(playerId, message, abilities);
playerQueryEventSource.target(playerId, message + getControllingPlayerHint(playerId), abilities);
}
@Override
@ -3095,7 +3109,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.target(playerId, message, perms, required);
playerQueryEventSource.target(playerId, message + getControllingPlayerHint(playerId), perms, required);
}
@Override
@ -3103,7 +3117,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.amount(playerId, message, min, max);
playerQueryEventSource.amount(playerId, message + getControllingPlayerHint(playerId), min, max);
}
@Override
@ -3128,7 +3142,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
playerQueryEventSource.choosePile(playerId, message, pile1, pile2);
playerQueryEventSource.choosePile(playerId, message + getControllingPlayerHint(playerId), pile1, pile2);
}
@Override

View file

@ -1,5 +1,3 @@
package mage.game.turn;
import mage.constants.TurnPhase;

View file

@ -1,14 +1,12 @@
package mage.game.turn;
import java.util.UUID;
import mage.constants.PhaseStep;
import mage.game.Game;
import mage.game.events.GameEvent.EventType;
import mage.players.Player;
import java.util.UUID;
/**
* @author BetaSteward_at_googlemail.com
*/
@ -30,19 +28,31 @@ public class CleanupStep extends Step {
super.beginStep(game, activePlayerId);
Player activePlayer = game.getPlayer(activePlayerId);
game.getState().setPriorityPlayerId(activePlayer.getId());
//20091005 - 514.1
// 514.1
// First, if the active players hand contains more cards than his or her maximum hand size
// (normally seven), he or she discards enough cards to reduce his or her hand size to that number.
// This turn-based action doesnt use the stack.
if (activePlayer.isInGame()) {
activePlayer.discardToMax(game);
}
//20100423 - 514.2
// 514.2
// Second, the following actions happen simultaneously: all damage marked on permanents
// (including phased-out permanents) is removed and all "until end of turn" and "this turn"
// effects end. This turn-based action doesnt use the stack.
game.getBattlefield().endOfTurn(game);
game.getState().removeEotEffects(game);
// 514.3
// Normally, no player receives priority during the cleanup step, so no spells can be cast
// and no abilities can be activated. However, this rule is subject to the following exception: 514.3a
//
// Look at EndPhase code to process 514.3
}
@Override
public void endStep(Game game, UUID activePlayerId) {
Player activePlayer = game.getPlayer(activePlayerId);
activePlayer.setGameUnderYourControl(true);
super.endStep(game, activePlayerId);
}

View file

@ -31,16 +31,21 @@ public class EndPhase extends Phase {
game.getState().increaseStepNum();
game.getTurn().setEndTurnRequested(false); // so triggers trigger again
prePriority(game, activePlayerId);
// 514.3a At this point, the game checks to see if any state-based actions would be performed
// 514.3.
// Normally, no player receives priority during the cleanup step, so no spells can be cast and
// no abilities can be activated. However, this rule is subject to the following exception:
// 514.3a
// At this point, the game checks to see if any state-based actions would be performed
// and/or any triggered abilities are waiting to be put onto the stack (including those that
// trigger "at the beginning of the next cleanup step"). If so, those state-based actions are
// performed, then those triggered abilities are put on the stack, then the active player gets
// priority. Players may cast spells and activate abilities. Once the stack is empty and all players
// pass in succession, another cleanup step begins
if (game.checkStateAndTriggered()) {
// Queues a new cleanup step
game.informPlayers("State-based actions or triggers happened on cleanup step, so players get priority due 514.3a");
// queues a new cleanup step and request new priorities
game.getState().getTurnMods().add(new TurnMod(activePlayerId).withExtraStep(new CleanupStep()));
// resume priority
if (!game.isPaused() && !game.checkIfGameIsOver() && !game.executingRollback()) {
currentStep.priority(game, activePlayerId, false);
if (game.executingRollback()) {

View file

@ -60,6 +60,12 @@ public abstract class Step implements Serializable, Copyable<Step> {
stepPart = StepPart.PRE;
}
/**
* Play priority by all players
*
* @param activePlayerId starting priority player
* @param resuming false to reset passed priority and ask it again
*/
public void priority(Game game, UUID activePlayerId, boolean resuming) {
if (hasPriority) {
stepPart = StepPart.PRIORITY;

View file

@ -107,12 +107,16 @@ public class Turn implements Serializable {
));
return true;
}
logStartOfTurn(game, activePlayer);
checkTurnIsControlledByOtherPlayer(game, activePlayer.getId());
logStartOfTurn(game, activePlayer);
resetCounts();
this.activePlayerId = activePlayer.getId();
resetCounts();
this.currentPhase = null;
// turn control must be called after potential turn skip due 720.1.
checkTurnIsControlledByOtherPlayer(game, activePlayer.getId());
game.getPlayer(activePlayer.getId()).beginTurn(game);
for (Phase phase : phases) {
if (game.isPaused() || game.checkIfGameIsOver()) {
@ -220,8 +224,31 @@ public class Turn implements Serializable {
}
private void checkTurnIsControlledByOtherPlayer(Game game, UUID activePlayerId) {
// 720.1.
// Some cards allow a player to control another player during that players next turn.
// This effect applies to the next turn that the affected player actually takes.
// The affected player is controlled during the entire turn; the effect doesnt end until
// the beginning of the next turn.
//
// 720.1b
// If a turn is skipped, any pending player-controlling effects wait until the player who would be
// affected actually takes a turn.
// remove old under control
game.getPlayers().values().forEach(player -> {
if (player.isInGame() && !player.isGameUnderControl()) {
Player controllingPlayer = game.getPlayer(player.getTurnControlledBy());
if (player != controllingPlayer && controllingPlayer != null) {
game.informPlayers(controllingPlayer.getLogName() + " lost control over " + player.getLogName());
}
player.setGameUnderYourControl(true);
}
});
// add new under control
TurnMod newControllerMod = game.getState().getTurnMods().useNextNewController(activePlayerId);
if (newControllerMod != null && !newControllerMod.getNewControllerId().equals(activePlayerId)) {
// set player under new control
// game logs added in child's call (controlPlayersTurn)
game.getPlayer(newControllerMod.getNewControllerId()).controlPlayersTurn(game, activePlayerId, newControllerMod.getInfo());
}

View file

@ -366,7 +366,7 @@ public interface Player extends MageItem, Copyable<Player> {
boolean isGameUnderControl();
/**
* Returns false in case you don't control the game.
* False in case you don't control the game.
* <p>
* Note: For effects like "You control target player during that player's
* next turn".

View file

@ -7,7 +7,6 @@ import mage.abilities.ActivatedAbility.ActivationStatus;
import mage.abilities.common.PassAbility;
import mage.abilities.common.PlayLandAsCommanderAbility;
import mage.abilities.common.WhileSearchingPlayFromLibraryAbility;
import mage.abilities.common.delayed.AtTheEndOfTurnStepPostDelayedTriggeredAbility;
import mage.abilities.costs.*;
import mage.abilities.costs.mana.AlternateManaPaymentAbility;
import mage.abilities.costs.mana.ManaCost;
@ -15,7 +14,6 @@ import mage.abilities.costs.mana.ManaCosts;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.abilities.effects.RestrictionEffect;
import mage.abilities.effects.RestrictionUntapNotMoreThanEffect;
import mage.abilities.effects.common.LoseControlOnOtherPlayersControllerEffect;
import mage.abilities.keyword.*;
import mage.abilities.mana.ActivatedManaAbilityImpl;
import mage.abilities.mana.ManaOptions;
@ -609,11 +607,7 @@ public abstract class PlayerImpl implements Player, Serializable {
if (!playerUnderControl.hasLeft() && !playerUnderControl.hasLost()) {
playerUnderControl.setGameUnderYourControl(false);
}
DelayedTriggeredAbility ability = new AtTheEndOfTurnStepPostDelayedTriggeredAbility(
new LoseControlOnOtherPlayersControllerEffect(this.getLogName(), playerUnderControl.getLogName()));
ability.setSourceId(getId());
ability.setControllerId(getId());
game.addDelayedTriggeredAbility(ability, null);
// control will reset on start of the turn
}
}

View file

@ -2127,9 +2127,10 @@ public final class CardUtil {
return null;
}
// not started game
// T0 - for not started game
// T2 - for starting of the turn
if (gameState.getTurn().getStep() == null) {
return "T0";
return "T" + gameState.getTurnNum();
}
// normal game

View file

@ -160,10 +160,6 @@ public final class GameLog {
return "<font color='" + LOG_COLOR_PLAYER_CONFIRM + "'>" + name + "</font>";
}
public static String getSmallSecondLineText(String text) {
return "<div style='font-size:11pt'>" + text + "</div>";
}
private static String getColorName(ObjectColor objectColor) {
if (objectColor.isMulticolored()) {
return LOG_COLOR_MULTI;