diff --git a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java index 6d36ed8444d..6e86e0d082c 100644 --- a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java +++ b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayer6.java @@ -414,7 +414,7 @@ public class ComputerPlayer6 extends ComputerPlayer { Target target = effect.getTarget(); if (!target.doneChoosing()) { for (UUID targetId : target.possibleTargets(stackObject.getControllerId(), stackObject.getStackAbility(), game)) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); StackAbility newAbility = (StackAbility) stackObject.copy(); SearchEffect newEffect = getSearchEffect(newAbility); newEffect.getTarget().addTarget(targetId, newAbility, sim); @@ -514,8 +514,7 @@ public class ComputerPlayer6 extends ComputerPlayer { logger.info("Sim Prio [" + depth + "] -- interrupted"); break; } - Game sim = game.copy(); - sim.setSimulation(true); + Game sim = game.createSimulationForAI(); if (!(action instanceof StaticAbility) //for MorphAbility, etc && sim.getPlayer(currentPlayer.getId()).activateAbility((ActivatedAbility) action.copy(), sim)) { sim.applyEffects(); @@ -1067,8 +1066,7 @@ public class ComputerPlayer6 extends ComputerPlayer { * @return a new game object with simulated players */ protected Game createSimulation(Game game) { - Game sim = game.copy(); - sim.setSimulation(true); + Game sim = game.createSimulationForAI(); for (Player oldPlayer : sim.getState().getPlayers().values()) { // replace original player by simulated player and find result (execute/resolve current action) Player origPlayer = game.getState().getPlayers().get(oldPlayer.getId()).copy(); diff --git a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java index 2a69f9a69c7..7b21d1b3017 100644 --- a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java +++ b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/SimulatedPlayer2.java @@ -65,8 +65,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer { public List simulatePriority(Game game) { allActions = new ConcurrentLinkedQueue<>(); - Game sim = game.copy(); - sim.setSimulation(true); + Game sim = game.createSimulationForAI(); forced = false; simulateOptions(sim); @@ -170,17 +169,6 @@ public final class SimulatedPlayer2 extends ComputerPlayer { } -// protected void simulateAction(Game game, SimulatedAction previousActions, Ability action) { -// List actions = new ArrayList(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 * @@ -322,7 +310,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer { int powerElements = (int) Math.pow(2, attackersList.size()); StringBuilder binary = new StringBuilder(); for (int i = powerElements - 1; i >= 0; i--) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); binary.setLength(0); binary.append(Integer.toBinaryString(i)); while (binary.length() < attackersList.size()) { @@ -360,7 +348,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer { } //add a node with no blockers - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); engagements.put(sim.getCombat().getValue().hashCode(), sim.getCombat()); sim.fireEvent(GameEvent.getEvent(GameEvent.EventType.DECLARED_BLOCKERS, playerId, playerId)); @@ -381,7 +369,7 @@ public final class SimulatedPlayer2 extends ComputerPlayer { List remaining = remove(blockers, blocker); for (int i = 0; i < numGroups; i++) { 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); if (engagements.put(sim.getCombat().getValue().hashCode(), sim.getCombat()) != null) { 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) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); sim.getStack().push(new StackAbility(ability, playerId)); if (ability.activate(sim, false) && ability.isUsesStack()) { game.fireEvent(new GameEvent(GameEvent.EventType.TRIGGERED_ABILITY, ability.getId(), ability, ability.getControllerId())); diff --git a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/util/CombatUtil.java b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/util/CombatUtil.java index e17c8ea8c53..87bb4136d15 100644 --- a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/util/CombatUtil.java +++ b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/util/CombatUtil.java @@ -202,7 +202,7 @@ public final class CombatUtil { * @deprecated TODO: unused, can be deleted? */ 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?) 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) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); // TODO: bugged, miss combat.clear code (possible bugs - wrong blocker declare by AI on multiple options?) Combat combat = sim.getCombat(); diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java index f9319d6d766..e5c2adf7a16 100644 --- a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/ComputerPlayerMCTS.java @@ -274,7 +274,7 @@ public class ComputerPlayerMCTS extends ComputerPlayer { * @return a new game object with simulated players */ protected Game createMCTSGame(Game game) { - Game mcts = game.copy(); + Game mcts = game.createSimulationForAI(); for (Player copyPlayer : mcts.getState().getPlayers().values()) { 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.setSimulation(true); mcts.resume(); return mcts; } diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java index 11e5c83e8ee..bfb5669af1e 100644 --- a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/MCTSNode.java @@ -245,7 +245,7 @@ public class MCTSNode { * @return a new game object with simulated players */ protected Game createSimulation(Game game, UUID playerId) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); for (Player oldPlayer: sim.getState().getPlayers().values()) { Player origPlayer = game.getState().getPlayers().get(oldPlayer.getId()).copy(); @@ -254,7 +254,6 @@ public class MCTSNode { sim.getState().getPlayers().put(oldPlayer.getId(), newPlayer); } randomizePlayers(sim, playerId); - sim.setSimulation(true); return sim; } diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/PriorityNextAction.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/PriorityNextAction.java index ac38b8969e3..a08345ad44a 100644 --- a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/PriorityNextAction.java +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/PriorityNextAction.java @@ -18,7 +18,7 @@ public class PriorityNextAction implements MCTSNodeNextAction{ else abilities = MCTSNode.getPlayables(player, fullStateValue, game); for (Ability ability: abilities) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); simPlayer.activateAbility((ActivatedAbility)ability, sim); sim.resume(); diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectAttackersNextAction.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectAttackersNextAction.java index 3627dd74d92..3a3919c9489 100644 --- a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectAttackersNextAction.java +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectAttackersNextAction.java @@ -19,7 +19,7 @@ public class SelectAttackersNextAction implements MCTSNodeNextAction{ attacks = getAttacks(player, fullStateValue, game); UUID defenderId = game.getOpponents(player.getId()).iterator().next(); for (List attack: attacks) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); for (UUID attackerId: attack) { simPlayer.declareAttacker(attackerId, defenderId, sim, false); diff --git a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectBlockersNextAction.java b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectBlockersNextAction.java index ec032fa32bf..def9823a497 100644 --- a/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectBlockersNextAction.java +++ b/Mage.Server.Plugins/Mage.Player.AIMCTS/src/mage/player/ai/SelectBlockersNextAction.java @@ -19,7 +19,7 @@ public class SelectBlockersNextAction implements MCTSNodeNextAction{ else blocks = getBlocks(player, fullStateValue, game); for (List> block : blocks) { - Game sim = game.copy(); + Game sim = game.createSimulationForAI(); MCTSPlayer simPlayer = (MCTSPlayer) sim.getPlayer(player.getId()); List groups = sim.getCombat().getGroups(); for (int i = 0; i < groups.size(); i++) { diff --git a/Mage.Server/src/main/java/mage/server/game/GameSessionWatcher.java b/Mage.Server/src/main/java/mage/server/game/GameSessionWatcher.java index d05c1641f05..3bb4fefae3a 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameSessionWatcher.java +++ b/Mage.Server/src/main/java/mage/server/game/GameSessionWatcher.java @@ -97,7 +97,8 @@ public class GameSessionWatcher { } 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(); GameView gameView = new GameView(sourceGame.getState(), sourceGame, null, userId); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/continuous/TappedForManaFromMultipleEffects.java b/Mage.Tests/src/test/java/org/mage/test/cards/continuous/TappedForManaFromMultipleEffects.java index 863304ee0ae..f4b260a15a5 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/continuous/TappedForManaFromMultipleEffects.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/continuous/TappedForManaFromMultipleEffects.java @@ -53,9 +53,10 @@ public class TappedForManaFromMultipleEffects extends CardTestPlayerBase { // cast nyx 2 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: 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 - setChoice(playerA, "Nyxbloom Ancient"); // getPlayable... checkTappedForManaReplacement... chooseReplacementEffect - setChoice(playerA, "Nyxbloom Ancient"); // playManaAbility... resolve... checkToFirePossibleEvents... chooseReplacementEffect + setChoice(playerA, "Nyxbloom Ancient"); // x2 replacement effects from x2 nyx + //setChoice(playerA, "Nyxbloom Ancient"); // wrongly choice from playable calc - no need after bug fix // cast chloro castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Chlorophant"); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/flip/SasayaOrochiAscendantTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/flip/SasayaOrochiAscendantTest.java index 07dd3f2344d..94e84ed813d 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/flip/SasayaOrochiAscendantTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/flip/SasayaOrochiAscendantTest.java @@ -11,15 +11,48 @@ import org.mage.test.serverside.base.CardTestPlayerBase; import static org.mage.test.utils.ManaOptionsTestUtils.assertManaOptions; /** - * * @author LevelX2 */ 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 public void testSasayasEssence() { addCard(Zone.HAND, playerA, "Plains", 7); 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. // Sasaya's Essence: Legendary Enchantment @@ -123,7 +156,7 @@ public class SasayaOrochiAscendantTest extends CardTestPlayerBase { assertManaOptions("{R}{R}{R}{R}{G}{G}{G}{G}{G}", manaOptions); assertManaOptions("{R}{R}{R}{G}{G}{G}{G}{G}{G}", manaOptions); assertManaOptions("{R}{R}{G}{G}{G}{G}{G}{G}{G}", manaOptions); - assertManaOptions("{R}{G}{G}{G}{G}{G}{G}{G}{G}", manaOptions); + assertManaOptions("{R}{G}{G}{G}{G}{G}{G}{G}{G}", manaOptions); } } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/khm/ValkiGodOfLiesTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/khm/ValkiGodOfLiesTest.java index 0bc16e33f06..b50d6e0ea6b 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/single/khm/ValkiGodOfLiesTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/khm/ValkiGodOfLiesTest.java @@ -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."); playLand(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Plains"); 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); setStopAt(1, PhaseStep.END_TURN); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/soi/TheGitrogMonsterTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/soi/TheGitrogMonsterTest.java index b71bfba9393..8caa28f6d6c 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/single/soi/TheGitrogMonsterTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/soi/TheGitrogMonsterTest.java @@ -7,12 +7,12 @@ import org.mage.test.serverside.base.CardTestPlayerBase; /** * 3BG Legendary Creature - Frog Horror Deathtouch - * + *

* At the beginning of your upkeep, sacrifice The Gitrog Monster unless you * sacrifice a land. - * + *

* You may play an additional land on each of your turns. - * + *

* Whenever one or more land cards are put into your graveyard from anywhere, * draw a card. * @@ -35,6 +35,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase { castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Armageddon"); + setStrictChooseMode(true); setStopAt(3, PhaseStep.DRAW); execute(); @@ -58,10 +59,10 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase { castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); // 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 -// addTarget(playerA, "Swamp"); -// setChoice(playerA, true); + setChoice(playerA, true); // sac land + setChoice(playerA, "Swamp"); // sac land + setStrictChooseMode(true); setStopAt(3, PhaseStep.DRAW); execute(); @@ -88,6 +89,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase { castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Planar Outburst"); + setStrictChooseMode(true); setStopAt(3, PhaseStep.DRAW); 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 * issue #3251 - * + *

* I took control of a Gitrog Monster, while the Gitrog Monster's owner * 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.BATTLEFIELD, playerB, "Island", 7); + // first land playLand(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Swamp"); + // cast gitrog and second land castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "The Gitrog Monster"); 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"); setChoice(playerA, "The Gitrog Monster"); @@ -137,6 +142,7 @@ public class TheGitrogMonsterTest extends CardTestPlayerBase { castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Toxic Deluge"); setChoice(playerA, "X=6"); + setStrictChooseMode(true); setStopAt(3, PhaseStep.BEGIN_COMBAT); execute(); diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java b/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java index d027c6eba5e..aaa52a86cb2 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java @@ -27,6 +27,7 @@ import mage.player.ai.ComputerPlayerMCTS; import mage.players.ManaPool; import mage.players.Player; import mage.server.game.GameSessionPlayer; +import mage.util.ThreadUtils; import mage.utils.SystemUtil; import mage.util.CardUtil; 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."); } + ThreadUtils.ensureRunInGameThread(); + // check stop command int maxTurn = 1; int maxPhase = 0; diff --git a/Mage/src/main/java/mage/abilities/AbilityImpl.java b/Mage/src/main/java/mage/abilities/AbilityImpl.java index 8665ab1d665..198318a8b7f 100644 --- a/Mage/src/main/java/mage/abilities/AbilityImpl.java +++ b/Mage/src/main/java/mage/abilities/AbilityImpl.java @@ -169,6 +169,7 @@ public abstract class AbilityImpl implements Ability { if (checkIfClause(game)) { // Ability has started resolving. Fire event. // 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())); if (this instanceof TriggeredAbility) { for (UUID modeId : this.getModes().getSelectedModes()) { diff --git a/Mage/src/main/java/mage/abilities/DelayedTriggeredAbilities.java b/Mage/src/main/java/mage/abilities/DelayedTriggeredAbilities.java index ff5288391fb..52d9892d00c 100644 --- a/Mage/src/main/java/mage/abilities/DelayedTriggeredAbilities.java +++ b/Mage/src/main/java/mage/abilities/DelayedTriggeredAbilities.java @@ -24,23 +24,22 @@ public class DelayedTriggeredAbilities extends AbilitiesImpl 0) { - for (Iterator it = this.iterator(); it.hasNext(); ) { - DelayedTriggeredAbility ability = it.next(); - if (ability.getDuration() == Duration.Custom) { - if (ability.isInactive(game)) { - it.remove(); - continue; - } - } - if (!ability.checkEventType(event, game)) { + // TODO: add same integrity checks as TriggeredAbilities?! + for (Iterator it = this.iterator(); it.hasNext(); ) { + DelayedTriggeredAbility ability = it.next(); + if (ability.getDuration() == Duration.Custom) { + if (ability.isInactive(game)) { + it.remove(); continue; } - if (ability.checkTrigger(event, game)) { - ability.trigger(game, ability.controllerId, event); - if (ability.getTriggerOnlyOnce()) { - it.remove(); - } + } + if (!ability.checkEventType(event, game)) { + continue; + } + if (ability.checkTrigger(event, game)) { + ability.trigger(game, ability.controllerId, event); + if (ability.getTriggerOnlyOnce()) { + it.remove(); } } } diff --git a/Mage/src/main/java/mage/abilities/PlayLandAbility.java b/Mage/src/main/java/mage/abilities/PlayLandAbility.java index aef18b74474..0288f7d415f 100644 --- a/Mage/src/main/java/mage/abilities/PlayLandAbility.java +++ b/Mage/src/main/java/mage/abilities/PlayLandAbility.java @@ -6,7 +6,9 @@ import mage.constants.AbilityType; import mage.constants.AsThoughEffectType; import mage.constants.Zone; import mage.game.Game; +import mage.players.Player; +import java.util.HashMap; import java.util.Set; import java.util.UUID; diff --git a/Mage/src/main/java/mage/abilities/TriggeredAbilities.java b/Mage/src/main/java/mage/abilities/TriggeredAbilities.java index 6015c93ae87..239438ac6ac 100644 --- a/Mage/src/main/java/mage/abilities/TriggeredAbilities.java +++ b/Mage/src/main/java/mage/abilities/TriggeredAbilities.java @@ -8,56 +8,203 @@ import mage.game.events.NumberOfTriggersEvent; import mage.game.permanent.Permanent; import mage.game.stack.Spell; import mage.util.CardUtil; +import mage.util.Copyable; import org.apache.log4j.Logger; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; /** - * @author BetaSteward_at_googlemail.com - *

- * This class uses ConcurrentHashMap to avoid ConcurrentModificationExceptions. - * See ticket https://github.com/magefree/mage/issues/966 and - * https://github.com/magefree/mage/issues/473 + * @author BetaSteward_at_googlemail.com, JayDi85 */ -public class TriggeredAbilities extends ConcurrentHashMap { +public class TriggeredAbilities extends LinkedHashMap implements Copyable { private static final Logger logger = Logger.getLogger(TriggeredAbilities.class); private final Map> 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 processingNeed = new ArrayList<>(); + private List processingDone = new ArrayList<>(); + public TriggeredAbilities() { } protected TriggeredAbilities(final TriggeredAbilities abilities) { + makeSureNotProcessing(null); + for (Map.Entry entry : abilities.entrySet()) { this.put(entry.getKey(), entry.getValue().copy()); } for (Map.Entry> entry : abilities.sources.entrySet()) { 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) { - for (Iterator it = this.values().iterator(); it.hasNext(); ) { - TriggeredAbility ability = it.next(); - if (ability instanceof StateTriggeredAbility && ((StateTriggeredAbility) ability).canTrigger(game)) { - checkTrigger(ability, null, game); + makeSureNotProcessing(null); + + processingStart(null); + boolean needErrorChecksOnEnd = true; + try { + for (Iterator 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) { - for (Iterator it = this.values().iterator(); it.hasNext(); ) { - TriggeredAbility ability = it.next(); - if (ability.checkEventType(event, game)) { - checkTrigger(ability, event, game); + processingStart(event); + boolean needErrorChecksOnEnd = true; + // must keep real object refs (not copies), cause check trigger code can change trigger's and effect's data like targets + ArrayList 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 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 needIds = new ArrayList<>(); + String needInfo = this.processingNeed.stream() + .peek(a -> needIds.add(a.getId())) + .map(t -> "- " + t) + .sorted() + .collect(Collectors.joining("\n")); + List 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) { // 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()); if (ability.isInUseableZone(game, object, event)) { if (event == null || !game.getContinuousEffects().preventedByRuleModification(event, ability, game, false)) { @@ -99,6 +246,9 @@ public class TriggeredAbilities extends ConcurrentHashMap { 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(); - void setCheckPlayableState(boolean checkPlayableState); - MageObject getLastKnownInformation(UUID objectId, Zone zone); CardState getLastKnownInformationCard(UUID objectId, Zone zone); diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index 7056db81e92..23c638cf8c2 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -98,8 +98,10 @@ public abstract class GameImpl implements Game { private transient Object customData; // 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 @@ -180,6 +182,7 @@ public abstract class GameImpl implements Game { protected GameImpl(final GameImpl game) { //this.customData = game.customData; // 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.checkPlayableState = game.checkPlayableState; @@ -248,13 +251,19 @@ public abstract class GameImpl implements Game { } @Override - public void setSimulation(boolean simulation) { - this.simulation = simulation; + public Game createSimulationForAI() { + Game res = this.copy(); + ((GameImpl) res).simulation = true; + ((GameImpl) res).aiGame = true; + return res; } @Override - public void setCheckPlayableState(boolean checkPlayableState) { - this.checkPlayableState = checkPlayableState; + public Game createSimulationForPlayableCalc() { + Game res = this.copy(); + ((GameImpl) res).simulation = true; + ((GameImpl) res).checkPlayableState = true; + return res; } @Override @@ -1602,6 +1611,10 @@ public abstract class GameImpl implements Game { @Override 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; infiniteLoopCounter = 0; int rollbackBookmarkOnPriorityStart = 0; @@ -1711,7 +1724,7 @@ public abstract class GameImpl implements Game { throw new MageException(UNIT_TESTS_ERROR_TEXT); } } finally { - setCheckPlayableState(false); + //setCheckPlayableState(false); // TODO: delete } state.getPlayerList().getNext(); } @@ -1730,7 +1743,7 @@ public abstract class GameImpl implements Game { } finally { resetLKI(); clearAllBookmarks(); - setCheckPlayableState(false); + //setCheckPlayableState(false); } } @@ -4045,8 +4058,24 @@ public abstract class GameImpl implements Game { @Override public String toString() { Player activePayer = this.getPlayer(this.getActivePlayerId()); + + // show non-standard game state (not part of the real game, e.g. AI or mana calculation) + List 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() - .append(this.isSimulation() ? "!!!SIMULATION!!! " : "") + .append(!simInfo.isEmpty() ? "!!!" + String.join(", ", simInfo) + "!!! " : "") .append(this.getGameType().toString()) .append("; ").append(CardUtil.getTurnInfo(this)) .append("; active: ").append((activePayer == null ? "none" : activePayer.getName())) diff --git a/Mage/src/main/java/mage/players/Player.java b/Mage/src/main/java/mage/players/Player.java index 499203ff2b1..cec01ea1cd7 100644 --- a/Mage/src/main/java/mage/players/Player.java +++ b/Mage/src/main/java/mage/players/Player.java @@ -820,7 +820,7 @@ public interface Player extends MageItem, Copyable { void updateRange(Game game); - ManaOptions getManaAvailable(Game game); + ManaOptions getManaAvailable(Game originalGame); void addAvailableTriggeredMana(List netManaAvailable); @@ -832,7 +832,7 @@ public interface Player extends MageItem, Copyable { PlayableObjectsList getPlayableObjects(Game game, Zone zone); - Map getPlayableActivatedAbilities(MageObject object, Zone zone, Game game); + Map getPlayableActivatedAbilities(MageObject object, Zone zone, Game originalGame); boolean addCounters(Counter counter, UUID playerAddingCounters, Ability source, Game game); diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index ae741fda0e0..f96ebfe7b11 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -181,6 +181,7 @@ public abstract class PlayerImpl implements Player, Serializable { // // A card may be able to cast multiple way with multiple methods. // The specific MageIdentifier should be checked, before checking null as a fallback. + // TODO: must rework playable methods to static protected Map> castSourceIdWithAlternateMana = new HashMap<>(); protected Map>> castSourceIdManaCosts = new HashMap<>(); protected Map>> castSourceIdCosts = new HashMap<>(); @@ -1755,23 +1756,19 @@ public abstract class PlayerImpl implements Player, Serializable { if (object instanceof StackAbility || object == null) { 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 needIds = CardUtil.getObjectParts(object); - // workaround to find all abilities first and filter it for one object - List allPlayable = getPlayable(game, true, zone, false); - for (ActivatedAbility ability : allPlayable) { - if (needIds.contains(ability.getSourceId())) { - useable.putIfAbsent(ability.getId(), ability); - } + // 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 needIds = CardUtil.getObjectParts(object); + + // workaround to find all abilities first and filter it for one object + List 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; } @@ -3376,13 +3373,13 @@ public abstract class PlayerImpl implements Player, Serializable { * combinations of mana are available to cast spells or activate abilities * etc. * - * @param game + * @param originalGame * @return */ @Override - public ManaOptions getManaAvailable(Game game) { - boolean oldState = game.inCheckPlayableState(); - game.setCheckPlayableState(true); + public ManaOptions getManaAvailable(Game originalGame) { + // workaround to fix a triggers list modification bug (game must be immutable on playable calculations) + Game game = originalGame.createSimulationForPlayableCalc(); ManaOptions availableMana = new ManaOptions(); availableMana.addMana(manaPool.getMana()); @@ -3477,8 +3474,9 @@ public abstract class PlayerImpl implements Player, Serializable { availableMana.removeFullyIncludedVariations(); 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 + for (MageIdentifier identifier : getCastSourceIdWithAlternateMana().getOrDefault(copy.getSourceId(), new HashSet<>())) { ManaCosts alternateCosts = getCastSourceIdManaCosts().get(copy.getSourceId()).get(identifier); Costs costs = getCastSourceIdCosts().get(copy.getSourceId()).get(identifier); @@ -4001,6 +4000,14 @@ public abstract class PlayerImpl implements Player, Serializable { approvingObjects = game.getContinuousEffects().asThough(object.getId(), 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 { // other abilities from direct zones approvingObjects = new HashSet<>(); @@ -4057,21 +4064,20 @@ public abstract class PlayerImpl implements Player, Serializable { * currently cast/activate with his available resources. * Without target validation. * - * @param game + * @param originalGame * @param hidden also from hidden objects (e.g. turned face down cards ?) * @param fromZone of objects from which zone (ALL = from all zones) * @param hideDuplicatedAbilities if equal abilities exist return only the * first instance * @return */ - public List getPlayable(Game game, boolean hidden, Zone fromZone, boolean hideDuplicatedAbilities) { + public List getPlayable(Game originalGame, boolean hidden, Zone fromZone, boolean hideDuplicatedAbilities) { List playable = new ArrayList<>(); - if (shouldSkipGettingPlayable(game)) { + if (shouldSkipGettingPlayable(originalGame)) { return playable; } - boolean previousState = game.inCheckPlayableState(); - game.setCheckPlayableState(true); + Game game = originalGame.createSimulationForPlayableCalc(); try { 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); @@ -4241,10 +4247,13 @@ public abstract class PlayerImpl implements Player, Serializable { playable.addAll(activatedAll); } } 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 * @return */ - private boolean shouldSkipGettingPlayable(Game game) { + static private boolean shouldSkipGettingPlayable(Game game) { if (game.getStep() == null) { // happens at the start of the game return true; } diff --git a/Mage/src/main/java/mage/util/ThreadUtils.java b/Mage/src/main/java/mage/util/ThreadUtils.java index de27d09b27f..e568aa7cb8a 100644 --- a/Mage/src/main/java/mage/util/ThreadUtils.java +++ b/Mage/src/main/java/mage/util/ThreadUtils.java @@ -55,11 +55,27 @@ public final class ThreadUtils { } public static void ensureRunInGameThread() { - String name = Thread.currentThread().getName(); - if (!name.startsWith("GAME")) { + if (!isRunGameThread()) { + // 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) // 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; } }