server: improved server stability (#11285) and reworked triggers/playable logic (#8426):

* game: now all playable calculations done in game simulation, outside real game (no more freeze and ruined games by wrong Nyxbloom Ancient and other cards with wrong replacement dialog);
* game: fixed multiple problems with triggers (wrong order, duplicated calls or "too many mana" bugs, see #8426, #12087);
* tests: added data integrity checks for game's triggers (3 enabled and 3 disabled due current game engine logic);
This commit is contained in:
Oleg Agafonov 2024-04-16 23:10:04 +04:00
parent f68e435fc4
commit e8e2f23284
23 changed files with 362 additions and 120 deletions

View file

@ -414,7 +414,7 @@ public class ComputerPlayer6 extends ComputerPlayer {
Target target = effect.getTarget(); Target target = effect.getTarget();
if (!target.doneChoosing()) { if (!target.doneChoosing()) {
for (UUID targetId : target.possibleTargets(stackObject.getControllerId(), stackObject.getStackAbility(), game)) { for (UUID targetId : target.possibleTargets(stackObject.getControllerId(), stackObject.getStackAbility(), game)) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
StackAbility newAbility = (StackAbility) stackObject.copy(); StackAbility newAbility = (StackAbility) stackObject.copy();
SearchEffect newEffect = getSearchEffect(newAbility); SearchEffect newEffect = getSearchEffect(newAbility);
newEffect.getTarget().addTarget(targetId, newAbility, sim); newEffect.getTarget().addTarget(targetId, newAbility, sim);
@ -514,8 +514,7 @@ public class ComputerPlayer6 extends ComputerPlayer {
logger.info("Sim Prio [" + depth + "] -- interrupted"); logger.info("Sim Prio [" + depth + "] -- interrupted");
break; break;
} }
Game sim = game.copy(); Game sim = game.createSimulationForAI();
sim.setSimulation(true);
if (!(action instanceof StaticAbility) //for MorphAbility, etc if (!(action instanceof StaticAbility) //for MorphAbility, etc
&& sim.getPlayer(currentPlayer.getId()).activateAbility((ActivatedAbility) action.copy(), sim)) { && sim.getPlayer(currentPlayer.getId()).activateAbility((ActivatedAbility) action.copy(), sim)) {
sim.applyEffects(); sim.applyEffects();
@ -1067,8 +1066,7 @@ public class ComputerPlayer6 extends ComputerPlayer {
* @return a new game object with simulated players * @return a new game object with simulated players
*/ */
protected Game createSimulation(Game game) { protected Game createSimulation(Game game) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
sim.setSimulation(true);
for (Player oldPlayer : sim.getState().getPlayers().values()) { for (Player oldPlayer : sim.getState().getPlayers().values()) {
// replace original player by simulated player and find result (execute/resolve current action) // replace original player by simulated player and find result (execute/resolve current action)
Player origPlayer = game.getState().getPlayers().get(oldPlayer.getId()).copy(); Player origPlayer = game.getState().getPlayers().get(oldPlayer.getId()).copy();

View file

@ -65,8 +65,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer {
public List<Ability> simulatePriority(Game game) { public List<Ability> simulatePriority(Game game) {
allActions = new ConcurrentLinkedQueue<>(); allActions = new ConcurrentLinkedQueue<>();
Game sim = game.copy(); Game sim = game.createSimulationForAI();
sim.setSimulation(true);
forced = false; forced = false;
simulateOptions(sim); simulateOptions(sim);
@ -170,17 +169,6 @@ public final class SimulatedPlayer2 extends ComputerPlayer {
} }
// protected void simulateAction(Game game, SimulatedAction previousActions, Ability action) {
// List<Ability> actions = new ArrayList<Ability>(previousActions.getAbilities());
// actions.add(action);
// Game sim = game.copy();
// if (sim.getPlayer(playerId).activateAbility((ActivatedAbility) action.copy(), sim)) {
// sim.applyEffects();
// sim.getPlayers().resetPassed();
// allActions.add(new SimulatedAction(sim, actions));
// }
// }
/** /**
* if suggested abilities exist, return only those from playables * if suggested abilities exist, return only those from playables
* *
@ -322,7 +310,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer {
int powerElements = (int) Math.pow(2, attackersList.size()); int powerElements = (int) Math.pow(2, attackersList.size());
StringBuilder binary = new StringBuilder(); StringBuilder binary = new StringBuilder();
for (int i = powerElements - 1; i >= 0; i--) { for (int i = powerElements - 1; i >= 0; i--) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
binary.setLength(0); binary.setLength(0);
binary.append(Integer.toBinaryString(i)); binary.append(Integer.toBinaryString(i));
while (binary.length() < attackersList.size()) { while (binary.length() < attackersList.size()) {
@ -360,7 +348,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer {
} }
//add a node with no blockers //add a node with no blockers
Game sim = game.copy(); Game sim = game.createSimulationForAI();
engagements.put(sim.getCombat().getValue().hashCode(), sim.getCombat()); engagements.put(sim.getCombat().getValue().hashCode(), sim.getCombat());
sim.fireEvent(GameEvent.getEvent(GameEvent.EventType.DECLARED_BLOCKERS, playerId, playerId)); sim.fireEvent(GameEvent.getEvent(GameEvent.EventType.DECLARED_BLOCKERS, playerId, playerId));
@ -381,7 +369,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer {
List<Permanent> remaining = remove(blockers, blocker); List<Permanent> remaining = remove(blockers, blocker);
for (int i = 0; i < numGroups; i++) { for (int i = 0; i < numGroups; i++) {
if (game.getCombat().getGroups().get(i).canBlock(blocker, game)) { if (game.getCombat().getGroups().get(i).canBlock(blocker, game)) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
sim.getCombat().getGroups().get(i).addBlocker(blocker.getId(), playerId, sim); sim.getCombat().getGroups().get(i).addBlocker(blocker.getId(), playerId, sim);
if (engagements.put(sim.getCombat().getValue().hashCode(), sim.getCombat()) != null) { if (engagements.put(sim.getCombat().getValue().hashCode(), sim.getCombat()) != null) {
logger.debug("simulating -- found redundant block combination"); logger.debug("simulating -- found redundant block combination");
@ -419,7 +407,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer {
} }
protected void addAbilityNode(SimulationNode2 parent, Ability ability, int depth, Game game) { protected void addAbilityNode(SimulationNode2 parent, Ability ability, int depth, Game game) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
sim.getStack().push(new StackAbility(ability, playerId)); sim.getStack().push(new StackAbility(ability, playerId));
if (ability.activate(sim, false) && ability.isUsesStack()) { if (ability.activate(sim, false) && ability.isUsesStack()) {
game.fireEvent(new GameEvent(GameEvent.EventType.TRIGGERED_ABILITY, ability.getId(), ability, ability.getControllerId())); game.fireEvent(new GameEvent(GameEvent.EventType.TRIGGERED_ABILITY, ability.getId(), ability, ability.getControllerId()));

View file

@ -202,7 +202,7 @@ public final class CombatUtil {
* @deprecated TODO: unused, can be deleted? * @deprecated TODO: unused, can be deleted?
*/ */
public static SurviveInfo willItSurvive(Game game, UUID attackingPlayerId, UUID defendingPlayerId, Permanent attacker, Permanent blocker) { public static SurviveInfo willItSurvive(Game game, UUID attackingPlayerId, UUID defendingPlayerId, Permanent attacker, Permanent blocker) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
// TODO: bugged, miss combat.clear code (possible bugs - wrong blocker declare by AI on multiple options?) // TODO: bugged, miss combat.clear code (possible bugs - wrong blocker declare by AI on multiple options?)
Combat combat = sim.getCombat(); Combat combat = sim.getCombat();
@ -307,7 +307,7 @@ public final class CombatUtil {
public static SurviveInfo willItSurvive2(Game game, UUID attackingPlayerId, UUID defendingPlayerId, Permanent attacker, Permanent blocker) { public static SurviveInfo willItSurvive2(Game game, UUID attackingPlayerId, UUID defendingPlayerId, Permanent attacker, Permanent blocker) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
// TODO: bugged, miss combat.clear code (possible bugs - wrong blocker declare by AI on multiple options?) // TODO: bugged, miss combat.clear code (possible bugs - wrong blocker declare by AI on multiple options?)
Combat combat = sim.getCombat(); Combat combat = sim.getCombat();

View file

@ -274,7 +274,7 @@ public class ComputerPlayerMCTS extends ComputerPlayer {
* @return a new game object with simulated players * @return a new game object with simulated players
*/ */
protected Game createMCTSGame(Game game) { protected Game createMCTSGame(Game game) {
Game mcts = game.copy(); Game mcts = game.createSimulationForAI();
for (Player copyPlayer : mcts.getState().getPlayers().values()) { for (Player copyPlayer : mcts.getState().getPlayers().values()) {
Player origPlayer = game.getState().getPlayers().get(copyPlayer.getId()); Player origPlayer = game.getState().getPlayers().get(copyPlayer.getId());
@ -295,7 +295,6 @@ public class ComputerPlayerMCTS extends ComputerPlayer {
} }
mcts.getState().getPlayers().put(copyPlayer.getId(), newPlayer); mcts.getState().getPlayers().put(copyPlayer.getId(), newPlayer);
} }
mcts.setSimulation(true);
mcts.resume(); mcts.resume();
return mcts; return mcts;
} }

View file

@ -245,7 +245,7 @@ public class MCTSNode {
* @return a new game object with simulated players * @return a new game object with simulated players
*/ */
protected Game createSimulation(Game game, UUID playerId) { protected Game createSimulation(Game game, UUID playerId) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
for (Player oldPlayer: sim.getState().getPlayers().values()) { for (Player oldPlayer: sim.getState().getPlayers().values()) {
Player origPlayer = game.getState().getPlayers().get(oldPlayer.getId()).copy(); Player origPlayer = game.getState().getPlayers().get(oldPlayer.getId()).copy();
@ -254,7 +254,6 @@ public class MCTSNode {
sim.getState().getPlayers().put(oldPlayer.getId(), newPlayer); sim.getState().getPlayers().put(oldPlayer.getId(), newPlayer);
} }
randomizePlayers(sim, playerId); randomizePlayers(sim, playerId);
sim.setSimulation(true);
return sim; return sim;
} }

View file

@ -18,7 +18,7 @@ public class PriorityNextAction implements MCTSNodeNextAction{
else else
abilities = MCTSNode.getPlayables(player, fullStateValue, game); abilities = MCTSNode.getPlayables(player, fullStateValue, game);
for (Ability ability: abilities) { for (Ability ability: abilities) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId());
simPlayer.activateAbility((ActivatedAbility)ability, sim); simPlayer.activateAbility((ActivatedAbility)ability, sim);
sim.resume(); sim.resume();

View file

@ -19,7 +19,7 @@ public class SelectAttackersNextAction implements MCTSNodeNextAction{
attacks = getAttacks(player, fullStateValue, game); attacks = getAttacks(player, fullStateValue, game);
UUID defenderId = game.getOpponents(player.getId()).iterator().next(); UUID defenderId = game.getOpponents(player.getId()).iterator().next();
for (List<UUID> attack: attacks) { for (List<UUID> attack: attacks) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId());
for (UUID attackerId: attack) { for (UUID attackerId: attack) {
simPlayer.declareAttacker(attackerId, defenderId, sim, false); simPlayer.declareAttacker(attackerId, defenderId, sim, false);

View file

@ -19,7 +19,7 @@ public class SelectBlockersNextAction implements MCTSNodeNextAction{
else else
blocks = getBlocks(player, fullStateValue, game); blocks = getBlocks(player, fullStateValue, game);
for (List<List<UUID>> block : blocks) { for (List<List<UUID>> block : blocks) {
Game sim = game.copy(); Game sim = game.createSimulationForAI();
MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId());
List<CombatGroup> groups = sim.getCombat().getGroups(); List<CombatGroup> groups = sim.getCombat().getGroups();
for (int i = 0; i < groups.size(); i++) { for (int i = 0; i < groups.size(); i++) {

View file

@ -97,7 +97,8 @@ public class GameSessionWatcher {
} }
public GameView getGameView() { public GameView getGameView() {
// game view calculation can take some time, so use copy for thread save (protection from ConcurrentModificationException) // game view calculation can take some time and can be called from non-game thread,
// so use copy for thread save (protection from ConcurrentModificationException)
Game sourceGame = game.copy(); Game sourceGame = game.copy();
GameView gameView = new GameView(sourceGame.getState(), sourceGame, null, userId); GameView gameView = new GameView(sourceGame.getState(), sourceGame, null, userId);

View file

@ -53,9 +53,10 @@ public class TappedForManaFromMultipleEffects extends CardTestPlayerBase {
// cast nyx 2 // cast nyx 2
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Nyxbloom Ancient"); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Nyxbloom Ancient");
// TODO: TAPPED_FOR_MANA replace event called from checkTappedForManaReplacement and start to choose replace events (is that problem?) // TODO: TAPPED_FOR_MANA replace event called from checkTappedForManaReplacement and start to choose replace events (is that problem?)
// TODO: yes, it's a problem, cause playable calc must not use dialogs!!!
// use case (that test): comment one 1-2 choices to fail in 1-2 calls // use case (that test): comment one 1-2 choices to fail in 1-2 calls
setChoice(playerA, "Nyxbloom Ancient"); // getPlayable... checkTappedForManaReplacement... chooseReplacementEffect setChoice(playerA, "Nyxbloom Ancient"); // x2 replacement effects from x2 nyx
setChoice(playerA, "Nyxbloom Ancient"); // playManaAbility... resolve... checkToFirePossibleEvents... chooseReplacementEffect //setChoice(playerA, "Nyxbloom Ancient"); // wrongly choice from playable calc - no need after bug fix
// cast chloro // cast chloro
castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Chlorophant"); castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Chlorophant");

View file

@ -11,15 +11,48 @@ import org.mage.test.serverside.base.CardTestPlayerBase;
import static org.mage.test.utils.ManaOptionsTestUtils.assertManaOptions; import static org.mage.test.utils.ManaOptionsTestUtils.assertManaOptions;
/** /**
*
* @author LevelX2 * @author LevelX2
*/ */
public class SasayaOrochiAscendantTest extends CardTestPlayerBase { public class SasayaOrochiAscendantTest extends CardTestPlayerBase {
@Test
public void test_SasayasEssence_SimpleManaCalculation() {
addCard(Zone.HAND, playerA, "Plains", 7);
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
// Reveal your hand: If you have seven or more land cards in your hand, flip Sasaya, Orochi Ascendant.
// Sasaya's Essence: Legendary Enchantment
// Whenever a land you control is tapped for mana, for each other land you control with the same name, add one mana of any type that land produced.
addCard(Zone.BATTLEFIELD, playerA, "Sasaya, Orochi Ascendant", 1);
//
// Mana pools don't empty as steps and phases end.
addCard(Zone.HAND, playerA, "Upwelling", 1); // Enchantment {3}{G}
//
// At the beginning of your upkeep, you gain 1 life.
addCard(Zone.BATTLEFIELD, playerB, "Fountain of Renewal", 1);
// prepare Sasaya's Essence
checkPermanentCount("before prepare", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Sasaya's Essence", 0);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Reveal your hand: If you have seven or more land cards in your hand, flip");
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("after prepare", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Sasaya's Essence", 1);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
// possible error: additional triggers from neutral card can break mana triggers and will calc wrong mana
// reason: random triggers order on triggers iterator, can be fixed by linked map usage
// x3 forest + x2 for each other forest
ManaOptions manaOptions = playerA.getManaAvailable(currentGame);
assertManaOptions("{G}{G}{G}" + "{G}{G}" + "{G}{G}" + "{G}{G}", manaOptions);
}
@Test @Test
public void testSasayasEssence() { public void testSasayasEssence() {
addCard(Zone.HAND, playerA, "Plains", 7); addCard(Zone.HAND, playerA, "Plains", 7);
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.BATTLEFIELD, playerB, "Fountain of Renewal", 5);
// Reveal your hand: If you have seven or more land cards in your hand, flip Sasaya, Orochi Ascendant. // Reveal your hand: If you have seven or more land cards in your hand, flip Sasaya, Orochi Ascendant.
// Sasaya's Essence: Legendary Enchantment // Sasaya's Essence: Legendary Enchantment

View file

@ -46,7 +46,7 @@ public class ValkiGodOfLiesTest extends CardTestPlayerBase {
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+2: Exile the top card of each player's library."); activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+2: Exile the top card of each player's library.");
playLand(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Plains"); playLand(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Plains");
castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Ephemerate", "Grizzly Bears"); castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Ephemerate", "Grizzly Bears");
setChoice(playerA, "Emblem Tibalt"); //setChoice(playerA, "Emblem Tibalt"); // wrongly x2 replacement effects - no need after bug fix
setStrictChooseMode(true); setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN); setStopAt(1, PhaseStep.END_TURN);

View file

@ -7,12 +7,12 @@ import org.mage.test.serverside.base.CardTestPlayerBase;
/** /**
* 3BG Legendary Creature - Frog Horror Deathtouch * 3BG Legendary Creature - Frog Horror Deathtouch
* * <p>
* At the beginning of your upkeep, sacrifice The Gitrog Monster unless you * At the beginning of your upkeep, sacrifice The Gitrog Monster unless you
* sacrifice a land. * sacrifice a land.
* * <p>
* You may play an additional land on each of your turns. * You may play an additional land on each of your turns.
* * <p>
* Whenever one or more land cards are put into your graveyard from anywhere, * Whenever one or more land cards are put into your graveyard from anywhere,
* draw a card. * draw a card.
* *
@ -35,6 +35,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase {
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster");
castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Armageddon"); castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Armageddon");
setStrictChooseMode(true);
setStopAt(3, PhaseStep.DRAW); setStopAt(3, PhaseStep.DRAW);
execute(); execute();
@ -58,10 +59,10 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase {
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster");
// on 3rd turn during upkeep opt to sacrifice a land // on 3rd turn during upkeep opt to sacrifice a land
// TODO: I don't know how to get these choices to work, let the choices go automatically setChoice(playerA, true); // sac land
// addTarget(playerA, "Swamp"); setChoice(playerA, "Swamp"); // sac land
// setChoice(playerA, true);
setStrictChooseMode(true);
setStopAt(3, PhaseStep.DRAW); setStopAt(3, PhaseStep.DRAW);
execute(); execute();
@ -88,6 +89,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase {
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster");
castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Planar Outburst"); castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Planar Outburst");
setStrictChooseMode(true);
setStopAt(3, PhaseStep.DRAW); setStopAt(3, PhaseStep.DRAW);
execute(); execute();
@ -98,7 +100,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase {
/** /**
* NOTE: As of 05/05/2017 this test is failing due to a bug in code. See * NOTE: As of 05/05/2017 this test is failing due to a bug in code. See
* issue #3251 * issue #3251
* * <p>
* I took control of a Gitrog Monster, while the Gitrog Monster's owner * I took control of a Gitrog Monster, while the Gitrog Monster's owner
* controlled a Dryad Arbor and cast Toxic Deluge for 6. * controlled a Dryad Arbor and cast Toxic Deluge for 6.
*/ */
@ -125,10 +127,13 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase {
addCard(Zone.GRAVEYARD, playerB, "Rags // Riches", 1); addCard(Zone.GRAVEYARD, playerB, "Rags // Riches", 1);
addCard(Zone.BATTLEFIELD, playerB, "Island", 7); addCard(Zone.BATTLEFIELD, playerB, "Island", 7);
// first land
playLand(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Swamp"); playLand(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Swamp");
// cast gitrog and second land
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster");
playLand(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Dryad Arbor"); playLand(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Dryad Arbor");
// change control to B, so no additional land for A
castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Riches"); castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Riches");
setChoice(playerA, "The Gitrog Monster"); setChoice(playerA, "The Gitrog Monster");
@ -137,6 +142,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase {
castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Toxic Deluge"); castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Toxic Deluge");
setChoice(playerA, "X=6"); setChoice(playerA, "X=6");
setStrictChooseMode(true);
setStopAt(3, PhaseStep.BEGIN_COMBAT); setStopAt(3, PhaseStep.BEGIN_COMBAT);
execute(); execute();

View file

@ -27,6 +27,7 @@ import mage.player.ai.ComputerPlayerMCTS;
import mage.players.ManaPool; import mage.players.ManaPool;
import mage.players.Player; import mage.players.Player;
import mage.server.game.GameSessionPlayer; import mage.server.game.GameSessionPlayer;
import mage.util.ThreadUtils;
import mage.utils.SystemUtil; import mage.utils.SystemUtil;
import mage.util.CardUtil; import mage.util.CardUtil;
import mage.view.GameView; import mage.view.GameView;
@ -236,6 +237,8 @@ public abstract class CardTestPlayerAPIImpl extends MageTestPlayerBase implement
throw new IllegalStateException("Game is not initialized. Use load method to load a test case and initialize a game."); throw new IllegalStateException("Game is not initialized. Use load method to load a test case and initialize a game.");
} }
ThreadUtils.ensureRunInGameThread();
// check stop command // check stop command
int maxTurn = 1; int maxTurn = 1;
int maxPhase = 0; int maxPhase = 0;

View file

@ -169,6 +169,7 @@ public abstract class AbilityImpl implements Ability {
if (checkIfClause(game)) { if (checkIfClause(game)) {
// Ability has started resolving. Fire event. // Ability has started resolving. Fire event.
// Used for abilities counting the number of resolutions like Ashling the Pilgrim. // Used for abilities counting the number of resolutions like Ashling the Pilgrim.
// TODO: called for mana abilities too, must be removed to safe place someday (see old place like StackAbility::resolve)
game.fireEvent(new GameEvent(GameEvent.EventType.RESOLVING_ABILITY, this.getOriginalId(), this, this.getControllerId())); game.fireEvent(new GameEvent(GameEvent.EventType.RESOLVING_ABILITY, this.getOriginalId(), this, this.getControllerId()));
if (this instanceof TriggeredAbility) { if (this instanceof TriggeredAbility) {
for (UUID modeId : this.getModes().getSelectedModes()) { for (UUID modeId : this.getModes().getSelectedModes()) {

View file

@ -24,23 +24,22 @@ public class DelayedTriggeredAbilities extends AbilitiesImpl<DelayedTriggeredAbi
} }
public void checkTriggers(GameEvent event, Game game) { public void checkTriggers(GameEvent event, Game game) {
if (this.size() > 0) { // TODO: add same integrity checks as TriggeredAbilities?!
for (Iterator<DelayedTriggeredAbility> it = this.iterator(); it.hasNext(); ) { for (Iterator<DelayedTriggeredAbility> it = this.iterator(); it.hasNext(); ) {
DelayedTriggeredAbility ability = it.next(); DelayedTriggeredAbility ability = it.next();
if (ability.getDuration() == Duration.Custom) { if (ability.getDuration() == Duration.Custom) {
if (ability.isInactive(game)) { if (ability.isInactive(game)) {
it.remove(); it.remove();
continue;
}
}
if (!ability.checkEventType(event, game)) {
continue; continue;
} }
if (ability.checkTrigger(event, game)) { }
ability.trigger(game, ability.controllerId, event); if (!ability.checkEventType(event, game)) {
if (ability.getTriggerOnlyOnce()) { continue;
it.remove(); }
} if (ability.checkTrigger(event, game)) {
ability.trigger(game, ability.controllerId, event);
if (ability.getTriggerOnlyOnce()) {
it.remove();
} }
} }
} }

View file

@ -6,7 +6,9 @@ import mage.constants.AbilityType;
import mage.constants.AsThoughEffectType; import mage.constants.AsThoughEffectType;
import mage.constants.Zone; import mage.constants.Zone;
import mage.game.Game; import mage.game.Game;
import mage.players.Player;
import java.util.HashMap;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;

View file

@ -8,56 +8,203 @@ import mage.game.events.NumberOfTriggersEvent;
import mage.game.permanent.Permanent; import mage.game.permanent.Permanent;
import mage.game.stack.Spell; import mage.game.stack.Spell;
import mage.util.CardUtil; import mage.util.CardUtil;
import mage.util.Copyable;
import org.apache.log4j.Logger; import org.apache.log4j.Logger;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors;
/** /**
* @author BetaSteward_at_googlemail.com * @author BetaSteward_at_googlemail.com, JayDi85
* <p>
* This class uses ConcurrentHashMap to avoid ConcurrentModificationExceptions.
* See ticket https://github.com/magefree/mage/issues/966 and
* https://github.com/magefree/mage/issues/473
*/ */
public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbility> { public class TriggeredAbilities extends LinkedHashMap<String, TriggeredAbility> implements Copyable<TriggeredAbilities> {
private static final Logger logger = Logger.getLogger(TriggeredAbilities.class); private static final Logger logger = Logger.getLogger(TriggeredAbilities.class);
private final Map<String, List<UUID>> sources = new HashMap<>(); private final Map<String, List<UUID>> sources = new HashMap<>();
// data integrity check for triggers
// reason: game engine can generate additional events and triggers while checking another one,
// it can generate multiple bugs, freeze, etc, see https://github.com/magefree/mage/issues/8426
// all checks can be catches by existing tests
private boolean enableIntegrityCheck1_MustKeepSameTriggersOrder = true; // good
private boolean enableIntegrityCheck2_MustKeepSameTriggersList = false; // bad, impossible to fix due dynamic triggers gen
private boolean enableIntegrityCheck3_CantStartEventProcessingBeforeFinishPrev = false; // bad, impossible to fix due dynamic triggers gen
private boolean enableIntegrityCheck4_EventMustProcessAllOldTriggers = true; // good
private boolean enableIntegrityCheck5_EventMustProcessInSameOrder = true; // good
private boolean enableIntegrityCheck6_EventMustNotProcessNewTriggers = false; // bad, impossible to fix due dynamic triggers gen
private boolean enableIntegrityLogs = false; // debug only
private boolean processingStarted = false;
private GameEvent.EventType processingStartedEvent = null; // null for game state triggers
private List<TriggeredAbility> processingNeed = new ArrayList<>();
private List<TriggeredAbility> processingDone = new ArrayList<>();
public TriggeredAbilities() { public TriggeredAbilities() {
} }
protected TriggeredAbilities(final TriggeredAbilities abilities) { protected TriggeredAbilities(final TriggeredAbilities abilities) {
makeSureNotProcessing(null);
for (Map.Entry<String, TriggeredAbility> entry : abilities.entrySet()) { for (Map.Entry<String, TriggeredAbility> entry : abilities.entrySet()) {
this.put(entry.getKey(), entry.getValue().copy()); this.put(entry.getKey(), entry.getValue().copy());
} }
for (Map.Entry<String, List<UUID>> entry : abilities.sources.entrySet()) { for (Map.Entry<String, List<UUID>> entry : abilities.sources.entrySet()) {
sources.put(entry.getKey(), entry.getValue()); sources.put(entry.getKey(), entry.getValue());
} }
this.enableIntegrityCheck1_MustKeepSameTriggersOrder = abilities.enableIntegrityCheck1_MustKeepSameTriggersOrder;
this.enableIntegrityCheck2_MustKeepSameTriggersList = abilities.enableIntegrityCheck2_MustKeepSameTriggersList;
this.enableIntegrityCheck3_CantStartEventProcessingBeforeFinishPrev = abilities.enableIntegrityCheck3_CantStartEventProcessingBeforeFinishPrev;
this.enableIntegrityCheck4_EventMustProcessAllOldTriggers = abilities.enableIntegrityCheck4_EventMustProcessAllOldTriggers;
this.enableIntegrityCheck5_EventMustProcessInSameOrder = abilities.enableIntegrityCheck5_EventMustProcessInSameOrder;
this.enableIntegrityCheck6_EventMustNotProcessNewTriggers = abilities.enableIntegrityCheck6_EventMustNotProcessNewTriggers;
this.enableIntegrityLogs = abilities.enableIntegrityLogs;
this.processingStarted = abilities.processingStarted;
this.processingStartedEvent = abilities.processingStartedEvent;
this.processingNeed = CardUtil.deepCopyObject(abilities.processingNeed);
this.processingDone = CardUtil.deepCopyObject(abilities.processingDone);
// runtime check: triggers order (not required by paper rules, by required by xmage to make same result for all game instances)
if (this.enableIntegrityCheck1_MustKeepSameTriggersOrder) {
if (!Objects.equals(this.values().stream().findFirst().orElse(null) + "",
abilities.values().stream().findFirst().orElse(null) + "")) {
// how-to fix: use LinkedHashMap instead HashMap/ConcurrentHashMap
throw new IllegalStateException("Triggers integrity failed: triggers order changed");
}
}
} }
public void checkStateTriggers(Game game) { public void checkStateTriggers(Game game) {
for (Iterator<TriggeredAbility> it = this.values().iterator(); it.hasNext(); ) { makeSureNotProcessing(null);
TriggeredAbility ability = it.next();
if (ability instanceof StateTriggeredAbility && ((StateTriggeredAbility) ability).canTrigger(game)) { processingStart(null);
checkTrigger(ability, null, game); boolean needErrorChecksOnEnd = true;
try {
for (Iterator<TriggeredAbility> it = this.values().iterator(); it.hasNext(); ) {
TriggeredAbility ability = it.next();
if (ability instanceof StateTriggeredAbility && ((StateTriggeredAbility) ability).canTrigger(game)) {
checkTrigger(ability, null, game);
}
this.processingDone(ability);
} }
} catch (Exception e) {
// need additional catch to show inner errors
needErrorChecksOnEnd = false;
throw e;
} finally {
processingEnd(needErrorChecksOnEnd);
} }
} }
public void checkTriggers(GameEvent event, Game game) { public void checkTriggers(GameEvent event, Game game) {
for (Iterator<TriggeredAbility> it = this.values().iterator(); it.hasNext(); ) { processingStart(event);
TriggeredAbility ability = it.next(); boolean needErrorChecksOnEnd = true;
if (ability.checkEventType(event, game)) { // must keep real object refs (not copies), cause check trigger code can change trigger's and effect's data like targets
checkTrigger(ability, event, game); ArrayList<TriggeredAbility> currentTriggers = new ArrayList<>(this.values());
try {
for (TriggeredAbility ability : currentTriggers) {
if (ability.checkEventType(event, game)) {
checkTrigger(ability, event, game);
}
this.processingDone(ability);
}
} catch (Exception e) {
// need additional catch to show inner errors
needErrorChecksOnEnd = false;
throw e;
} finally {
processingEnd(needErrorChecksOnEnd);
}
}
private void makeSureNotProcessing(GameEvent newEvent) {
if (this.enableIntegrityCheck2_MustKeepSameTriggersList
&& this.processingStarted) {
List<String> info = new ArrayList<>();
info.add("old event: " + this.processingStartedEvent);
info.add("new event: " + newEvent.getType());
// how-to fix: impossible until mana events/triggers rework cause one mana event can generate additional events/triggers
throw new IllegalArgumentException("Triggers integrity failed: triggers can't be modified while processing - "
+ String.join(", ", info));
}
}
private void processingStart(GameEvent newEvent) {
makeSureNotProcessing(newEvent);
this.processingStarted = true;
this.processingStartedEvent = newEvent == null ? null : newEvent.getType();
this.processingNeed.clear();
this.processingNeed.addAll(this.values());
this.processingDone.clear();
}
private void processingDone(TriggeredAbility trigger) {
this.processingDone.add(trigger);
}
private void processingEnd(boolean needErrorChecks) {
if (needErrorChecks) {
if (this.enableIntegrityCheck3_CantStartEventProcessingBeforeFinishPrev
&& !this.processingStarted) {
throw new IllegalArgumentException("Triggers integrity failed: can't finish event before start");
}
// must use ability's id to check equal (rules can be diff due usage of dynamic values - alternative to card hints)
List<UUID> needIds = new ArrayList<>();
String needInfo = this.processingNeed.stream()
.peek(a -> needIds.add(a.getId()))
.map(t -> "- " + t)
.sorted()
.collect(Collectors.joining("\n"));
List<UUID> doneIds = new ArrayList<>();
String doneInfo = this.processingDone.stream()
.peek(a -> doneIds.add(a.getId()))
.map(t -> "- " + t)
.sorted()
.collect(Collectors.joining("\n"));
String errorInfo = ""
+ "\n" + "Need: "
+ "\n" + (needInfo.isEmpty() ? "-" : needInfo)
+ "\n" + "Done: "
+ "\n" + (doneInfo.isEmpty() ? "-" : doneInfo);
if (this.enableIntegrityCheck4_EventMustProcessAllOldTriggers
&& this.processingDone.size() < this.processingNeed.size()) {
throw new IllegalArgumentException("Triggers integrity failed: event processing miss some triggers" + errorInfo);
}
if (this.enableIntegrityCheck5_EventMustProcessInSameOrder
&& this.processingDone.size() > 0
&& this.processingDone.size() == this.processingNeed.size()
&& !needIds.toString().equals(doneIds.toString())) {
throw new IllegalArgumentException("Triggers integrity failed: event processing used wrong order" + errorInfo);
}
if (this.enableIntegrityCheck6_EventMustNotProcessNewTriggers
&& this.processingDone.size() > this.processingNeed.size()) {
throw new IllegalArgumentException("Triggers integrity failed: event processing must not process new triggers" + errorInfo);
} }
} }
this.processingStarted = false;
this.processingStartedEvent = null;
this.processingNeed.clear();
this.processingDone.clear();
} }
private void checkTrigger(TriggeredAbility ability, GameEvent event, Game game) { private void checkTrigger(TriggeredAbility ability, GameEvent event, Game game) {
// for effects like when leaves battlefield or destroyed use ShortLKI to check if permanent was in the correct zone before (e.g. Oblivion Ring or Karmic Justice) // for effects like when leaves battlefield or destroyed use ShortLKI to check if permanent was in the correct zone before (e.g. Oblivion Ring or Karmic Justice)
if (this.enableIntegrityLogs) {
logger.info("---");
logger.info("checking trigger: " + ability);
logger.info("playable state: " + game.inCheckPlayableState());
logger.info(game);
logger.info("battlefield:" + "\n" + game.getBattlefield().getAllPermanents().stream()
.map(p -> "- " + p.toString())
.collect(Collectors.joining("\n")) + "\n");
}
MageObject object = game.getObject(ability.getSourceId()); MageObject object = game.getObject(ability.getSourceId());
if (ability.isInUseableZone(game, object, event)) { if (ability.isInUseableZone(game, object, event)) {
if (event == null || !game.getContinuousEffects().preventedByRuleModification(event, ability, game, false)) { if (event == null || !game.getContinuousEffects().preventedByRuleModification(event, ability, game, false)) {
@ -99,6 +246,9 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
if (event == null || !game.replaceEvent(numberOfTriggersEvent, ability)) { if (event == null || !game.replaceEvent(numberOfTriggersEvent, ability)) {
int numTriggers = ability.getTriggersOnceEachTurn() ? 1 : numberOfTriggersEvent.getAmount(); int numTriggers = ability.getTriggersOnceEachTurn() ? 1 : numberOfTriggersEvent.getAmount();
for (int i = 0; i < numTriggers; i++) { for (int i = 0; i < numTriggers; i++) {
if (this.enableIntegrityLogs) {
logger.info("trigger will be USED: " + ability);
}
ability.trigger(game, ability.getControllerId(), event); ability.trigger(game, ability.getControllerId(), event);
} }
} }
@ -115,6 +265,7 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
* @param attachedTo - the object that gained the ability * @param attachedTo - the object that gained the ability
*/ */
public void add(TriggeredAbility ability, UUID sourceId, MageObject attachedTo) { public void add(TriggeredAbility ability, UUID sourceId, MageObject attachedTo) {
makeSureNotProcessing(null);
if (sourceId == null) { if (sourceId == null) {
add(ability, attachedTo); add(ability, attachedTo);
} else if (attachedTo == null) { } else if (attachedTo == null) {
@ -130,6 +281,7 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
} }
public void add(TriggeredAbility ability, MageObject attachedTo) { public void add(TriggeredAbility ability, MageObject attachedTo) {
makeSureNotProcessing(null);
this.put(getKey(ability, attachedTo), ability); this.put(getKey(ability, attachedTo), ability);
} }
@ -162,8 +314,8 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
} }
@Override
public TriggeredAbilities copy() { public TriggeredAbilities copy() {
return new TriggeredAbilities(this); return new TriggeredAbilities(this);
} }
} }

View file

@ -269,12 +269,18 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
boolean isSimulation(); boolean isSimulation();
void setSimulation(boolean checkPlayableState); /**
* Prepare game for any simulations like AI or effects calc
*/
Game createSimulationForAI();
/**
* Prepare game for any playable calc (available mana/abilities)
*/
Game createSimulationForPlayableCalc();
boolean inCheckPlayableState(); boolean inCheckPlayableState();
void setCheckPlayableState(boolean checkPlayableState);
MageObject getLastKnownInformation(UUID objectId, Zone zone); MageObject getLastKnownInformation(UUID objectId, Zone zone);
CardState getLastKnownInformationCard(UUID objectId, Zone zone); CardState getLastKnownInformationCard(UUID objectId, Zone zone);

View file

@ -98,8 +98,10 @@ public abstract class GameImpl implements Game {
private transient Object customData; // temporary data, used in AI simulations private transient Object customData; // temporary data, used in AI simulations
private transient Player losingPlayer; // temporary data, used in AI simulations private transient Player losingPlayer; // temporary data, used in AI simulations
protected boolean simulation = false;
protected boolean checkPlayableState = false; protected boolean simulation = false; // for inner simulations (game without user messages)
protected boolean aiGame = false; // for inner simulations (ai game, debug only)
protected boolean checkPlayableState = false; // for inner playable calculations (game without user dialogs)
protected AtomicInteger totalErrorsCount = new AtomicInteger(); // for debug only: error stats protected AtomicInteger totalErrorsCount = new AtomicInteger(); // for debug only: error stats
@ -180,6 +182,7 @@ public abstract class GameImpl implements Game {
protected GameImpl(final GameImpl game) { protected GameImpl(final GameImpl game) {
//this.customData = game.customData; // temporary data, no need on game copy //this.customData = game.customData; // temporary data, no need on game copy
//this.losingPlayer = game.losingPlayer; // temporary data, no need on game copy //this.losingPlayer = game.losingPlayer; // temporary data, no need on game copy
this.aiGame = game.aiGame;
this.simulation = game.simulation; this.simulation = game.simulation;
this.checkPlayableState = game.checkPlayableState; this.checkPlayableState = game.checkPlayableState;
@ -248,13 +251,19 @@ public abstract class GameImpl implements Game {
} }
@Override @Override
public void setSimulation(boolean simulation) { public Game createSimulationForAI() {
this.simulation = simulation; Game res = this.copy();
((GameImpl) res).simulation = true;
((GameImpl) res).aiGame = true;
return res;
} }
@Override @Override
public void setCheckPlayableState(boolean checkPlayableState) { public Game createSimulationForPlayableCalc() {
this.checkPlayableState = checkPlayableState; Game res = this.copy();
((GameImpl) res).simulation = true;
((GameImpl) res).checkPlayableState = true;
return res;
} }
@Override @Override
@ -1602,6 +1611,10 @@ public abstract class GameImpl implements Game {
@Override @Override
public void playPriority(UUID activePlayerId, boolean resuming) { public void playPriority(UUID activePlayerId, boolean resuming) {
if (!this.isSimulation() && this.inCheckPlayableState()) {
throw new IllegalStateException("Wrong code usage. Only simulation games can be in CheckPlayableState");
}
int priorityErrorsCount = 0; int priorityErrorsCount = 0;
infiniteLoopCounter = 0; infiniteLoopCounter = 0;
int rollbackBookmarkOnPriorityStart = 0; int rollbackBookmarkOnPriorityStart = 0;
@ -1711,7 +1724,7 @@ public abstract class GameImpl implements Game {
throw new MageException(UNIT_TESTS_ERROR_TEXT); throw new MageException(UNIT_TESTS_ERROR_TEXT);
} }
} finally { } finally {
setCheckPlayableState(false); //setCheckPlayableState(false); // TODO: delete
} }
state.getPlayerList().getNext(); state.getPlayerList().getNext();
} }
@ -1730,7 +1743,7 @@ public abstract class GameImpl implements Game {
} finally { } finally {
resetLKI(); resetLKI();
clearAllBookmarks(); clearAllBookmarks();
setCheckPlayableState(false); //setCheckPlayableState(false);
} }
} }
@ -4045,8 +4058,24 @@ public abstract class GameImpl implements Game {
@Override @Override
public String toString() { public String toString() {
Player activePayer = this.getPlayer(this.getActivePlayerId()); Player activePayer = this.getPlayer(this.getActivePlayerId());
// show non-standard game state (not part of the real game, e.g. AI or mana calculation)
List<String> simInfo = new ArrayList<>();
if (this.simulation) {
simInfo.add("SIMULATION");
}
if (this.aiGame) {
simInfo.add("AI");
}
if (this.checkPlayableState) {
simInfo.add("PLAYABLE CALC");
}
if (!ThreadUtils.isRunGameThread()) {
simInfo.add("NOT GAME THREAD");
}
StringBuilder sb = new StringBuilder() StringBuilder sb = new StringBuilder()
.append(this.isSimulation() ? "!!!SIMULATION!!! " : "") .append(!simInfo.isEmpty() ? "!!!" + String.join(", ", simInfo) + "!!! " : "")
.append(this.getGameType().toString()) .append(this.getGameType().toString())
.append("; ").append(CardUtil.getTurnInfo(this)) .append("; ").append(CardUtil.getTurnInfo(this))
.append("; active: ").append((activePayer == null ? "none" : activePayer.getName())) .append("; active: ").append((activePayer == null ? "none" : activePayer.getName()))

View file

@ -820,7 +820,7 @@ public interface Player extends MageItem, Copyable<Player> {
void updateRange(Game game); void updateRange(Game game);
ManaOptions getManaAvailable(Game game); ManaOptions getManaAvailable(Game originalGame);
void addAvailableTriggeredMana(List<Mana> netManaAvailable); void addAvailableTriggeredMana(List<Mana> netManaAvailable);
@ -832,7 +832,7 @@ public interface Player extends MageItem, Copyable<Player> {
PlayableObjectsList getPlayableObjects(Game game, Zone zone); PlayableObjectsList getPlayableObjects(Game game, Zone zone);
Map<UUID, ActivatedAbility> getPlayableActivatedAbilities(MageObject object, Zone zone, Game game); Map<UUID, ActivatedAbility> getPlayableActivatedAbilities(MageObject object, Zone zone, Game originalGame);
boolean addCounters(Counter counter, UUID playerAddingCounters, Ability source, Game game); boolean addCounters(Counter counter, UUID playerAddingCounters, Ability source, Game game);

View file

@ -181,6 +181,7 @@ public abstract class PlayerImpl implements Player, Serializable {
// //
// A card may be able to cast multiple way with multiple methods. // A card may be able to cast multiple way with multiple methods.
// The specific MageIdentifier should be checked, before checking null as a fallback. // The specific MageIdentifier should be checked, before checking null as a fallback.
// TODO: must rework playable methods to static
protected Map<UUID, Set<MageIdentifier>> castSourceIdWithAlternateMana = new HashMap<>(); protected Map<UUID, Set<MageIdentifier>> castSourceIdWithAlternateMana = new HashMap<>();
protected Map<UUID, Map<MageIdentifier, ManaCosts<ManaCost>>> castSourceIdManaCosts = new HashMap<>(); protected Map<UUID, Map<MageIdentifier, ManaCosts<ManaCost>>> castSourceIdManaCosts = new HashMap<>();
protected Map<UUID, Map<MageIdentifier, Costs<Cost>>> castSourceIdCosts = new HashMap<>(); protected Map<UUID, Map<MageIdentifier, Costs<Cost>>> castSourceIdCosts = new HashMap<>();
@ -1755,23 +1756,19 @@ public abstract class PlayerImpl implements Player, Serializable {
if (object instanceof StackAbility || object == null) { if (object instanceof StackAbility || object == null) {
return useable; return useable;
} }
boolean previousState = game.inCheckPlayableState();
game.setCheckPlayableState(true);
try {
// collect and filter playable activated abilities
// GUI: user clicks on card, but it must activate ability from ANY card's parts (main, left, right)
Set<UUID> needIds = CardUtil.getObjectParts(object);
// workaround to find all abilities first and filter it for one object // collect and filter playable activated abilities
List<ActivatedAbility> allPlayable = getPlayable(game, true, zone, false); // GUI: user clicks on card, but it must activate ability from ANY card's parts (main, left, right)
for (ActivatedAbility ability : allPlayable) { Set<UUID> needIds = CardUtil.getObjectParts(object);
if (needIds.contains(ability.getSourceId())) {
useable.putIfAbsent(ability.getId(), ability); // workaround to find all abilities first and filter it for one object
} List<ActivatedAbility> allPlayable = getPlayable(game, true, zone, false);
for (ActivatedAbility ability : allPlayable) {
if (needIds.contains(ability.getSourceId())) {
useable.putIfAbsent(ability.getId(), ability);
} }
} finally {
game.setCheckPlayableState(previousState);
} }
return useable; return useable;
} }
@ -3376,13 +3373,13 @@ public abstract class PlayerImpl implements Player, Serializable {
* combinations of mana are available to cast spells or activate abilities * combinations of mana are available to cast spells or activate abilities
* etc. * etc.
* *
* @param game * @param originalGame
* @return * @return
*/ */
@Override @Override
public ManaOptions getManaAvailable(Game game) { public ManaOptions getManaAvailable(Game originalGame) {
boolean oldState = game.inCheckPlayableState(); // workaround to fix a triggers list modification bug (game must be immutable on playable calculations)
game.setCheckPlayableState(true); Game game = originalGame.createSimulationForPlayableCalc();
ManaOptions availableMana = new ManaOptions(); ManaOptions availableMana = new ManaOptions();
availableMana.addMana(manaPool.getMana()); availableMana.addMana(manaPool.getMana());
@ -3477,8 +3474,9 @@ public abstract class PlayerImpl implements Player, Serializable {
availableMana.removeFullyIncludedVariations(); availableMana.removeFullyIncludedVariations();
availableMana.remove(new Mana()); // Remove any empty mana that was left over from the way the code is written availableMana.remove(new Mana()); // Remove any empty mana that was left over from the way the code is written
game.setCheckPlayableState(oldState);
return availableMana; // make sure it independent of sim game
return availableMana.copy();
} }
/** /**
@ -3596,6 +3594,7 @@ public abstract class PlayerImpl implements Player, Serializable {
} }
// ALTERNATIVE COST FROM dynamic effects // ALTERNATIVE COST FROM dynamic effects
for (MageIdentifier identifier : getCastSourceIdWithAlternateMana().getOrDefault(copy.getSourceId(), new HashSet<>())) { for (MageIdentifier identifier : getCastSourceIdWithAlternateMana().getOrDefault(copy.getSourceId(), new HashSet<>())) {
ManaCosts alternateCosts = getCastSourceIdManaCosts().get(copy.getSourceId()).get(identifier); ManaCosts alternateCosts = getCastSourceIdManaCosts().get(copy.getSourceId()).get(identifier);
Costs<Cost> costs = getCastSourceIdCosts().get(copy.getSourceId()).get(identifier); Costs<Cost> costs = getCastSourceIdCosts().get(copy.getSourceId()).get(identifier);
@ -4001,6 +4000,14 @@ public abstract class PlayerImpl implements Player, Serializable {
approvingObjects = game.getContinuousEffects().asThough(object.getId(), approvingObjects = game.getContinuousEffects().asThough(object.getId(),
AsThoughEffectType.CAST_ADVENTURE_FROM_NOT_OWN_HAND_ZONE, ability, this.getId(), game); AsThoughEffectType.CAST_ADVENTURE_FROM_NOT_OWN_HAND_ZONE, ability, this.getId(), game);
} }
// TODO: warning, PLAY_FROM_NOT_OWN_HAND_ZONE save some playable info in player's castSourceXXX fields
// it must be reworked (available/playable methods must be static, all play info must be stored in GameState)
// Current workaround to sync sim info with real game, remove here and from regexp search: asThough\(.+AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE
Player simPlayer = game.getPlayer(this.getId());
this.castSourceIdCosts = new HashMap<>(simPlayer.getCastSourceIdCosts());
this.castSourceIdManaCosts = new HashMap<>(simPlayer.getCastSourceIdManaCosts());
this.castSourceIdWithAlternateMana = new HashMap<>(simPlayer.getCastSourceIdWithAlternateMana());
} else { } else {
// other abilities from direct zones // other abilities from direct zones
approvingObjects = new HashSet<>(); approvingObjects = new HashSet<>();
@ -4057,21 +4064,20 @@ public abstract class PlayerImpl implements Player, Serializable {
* currently cast/activate with his available resources. * currently cast/activate with his available resources.
* Without target validation. * Without target validation.
* *
* @param game * @param originalGame
* @param hidden also from hidden objects (e.g. turned face down cards ?) * @param hidden also from hidden objects (e.g. turned face down cards ?)
* @param fromZone of objects from which zone (ALL = from all zones) * @param fromZone of objects from which zone (ALL = from all zones)
* @param hideDuplicatedAbilities if equal abilities exist return only the * @param hideDuplicatedAbilities if equal abilities exist return only the
* first instance * first instance
* @return * @return
*/ */
public List<ActivatedAbility> getPlayable(Game game, boolean hidden, Zone fromZone, boolean hideDuplicatedAbilities) { public List<ActivatedAbility> getPlayable(Game originalGame, boolean hidden, Zone fromZone, boolean hideDuplicatedAbilities) {
List<ActivatedAbility> playable = new ArrayList<>(); List<ActivatedAbility> playable = new ArrayList<>();
if (shouldSkipGettingPlayable(game)) { if (shouldSkipGettingPlayable(originalGame)) {
return playable; return playable;
} }
boolean previousState = game.inCheckPlayableState(); Game game = originalGame.createSimulationForPlayableCalc();
game.setCheckPlayableState(true);
try { try {
ManaOptions availableMana = getManaAvailable(game); // get available mana options (mana pool and conditional mana added (but conditional still lose condition)) ManaOptions availableMana = getManaAvailable(game); // get available mana options (mana pool and conditional mana added (but conditional still lose condition))
boolean fromAll = fromZone.equals(Zone.ALL); boolean fromAll = fromZone.equals(Zone.ALL);
@ -4241,10 +4247,13 @@ public abstract class PlayerImpl implements Player, Serializable {
playable.addAll(activatedAll); playable.addAll(activatedAll);
} }
} finally { } finally {
game.setCheckPlayableState(previousState); //game.setCheckPlayableState(previousState); // TODO: delete
} }
return playable; // make sure it independent of sim game
return playable.stream()
.map(ActivatedAbility::copy)
.collect(Collectors.toList());
} }
/** /**
@ -4306,7 +4315,7 @@ public abstract class PlayerImpl implements Player, Serializable {
* @param game * @param game
* @return * @return
*/ */
private boolean shouldSkipGettingPlayable(Game game) { static private boolean shouldSkipGettingPlayable(Game game) {
if (game.getStep() == null) { // happens at the start of the game if (game.getStep() == null) { // happens at the start of the game
return true; return true;
} }

View file

@ -55,11 +55,27 @@ public final class ThreadUtils {
} }
public static void ensureRunInGameThread() { public static void ensureRunInGameThread() {
String name = Thread.currentThread().getName(); if (!isRunGameThread()) {
if (!name.startsWith("GAME")) { // for real games
// how-to fix: use signal logic to inform a game about new command to execute instead direct execute (see example with WantConcede) // how-to fix: use signal logic to inform a game about new command to execute instead direct execute (see example with WantConcede)
// reason: user responses/commands are received by network/call thread, but must be processed by game thread // reason: user responses/commands are received by network/call thread, but must be processed by game thread
throw new IllegalArgumentException("Wrong code usage: game related code must run in GAME thread, but it used in " + name, new Throwable()); //
// for unit tests
// how-to fix: if your test runner uses a diff thread name to run tests then add it to isRunGameThread
throw new IllegalArgumentException("Wrong code usage: game related code must run in GAME thread, but it used in " + Thread.currentThread().getName(), new Throwable());
}
}
public static boolean isRunGameThread() {
String name = Thread.currentThread().getName();
if (name.startsWith("GAME ")) {
// server game
return true;
} else if (name.equals("main")) {
// unit test
return true;
} else {
return false;
} }
} }