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

@ -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()) {

View file

@ -24,23 +24,22 @@ public class DelayedTriggeredAbilities extends AbilitiesImpl<DelayedTriggeredAbi
}
public void checkTriggers(GameEvent event, Game game) {
if (this.size() > 0) {
for (Iterator<DelayedTriggeredAbility> 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<DelayedTriggeredAbility> 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();
}
}
}

View file

@ -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;

View file

@ -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
* <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
* @author BetaSteward_at_googlemail.com, JayDi85
*/
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 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() {
}
protected TriggeredAbilities(final TriggeredAbilities abilities) {
makeSureNotProcessing(null);
for (Map.Entry<String, TriggeredAbility> entry : abilities.entrySet()) {
this.put(entry.getKey(), entry.getValue().copy());
}
for (Map.Entry<String, List<UUID>> 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<TriggeredAbility> 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<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) {
for (Iterator<TriggeredAbility> 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<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) {
// 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<String, TriggeredAbili
if (event == null || !game.replaceEvent(numberOfTriggersEvent, ability)) {
int numTriggers = ability.getTriggersOnceEachTurn() ? 1 : numberOfTriggersEvent.getAmount();
for (int i = 0; i < numTriggers; i++) {
if (this.enableIntegrityLogs) {
logger.info("trigger will be USED: " + ability);
}
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
*/
public void add(TriggeredAbility ability, UUID sourceId, MageObject attachedTo) {
makeSureNotProcessing(null);
if (sourceId == null) {
add(ability, attachedTo);
} else if (attachedTo == null) {
@ -130,6 +281,7 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
}
public void add(TriggeredAbility ability, MageObject attachedTo) {
makeSureNotProcessing(null);
this.put(getKey(ability, attachedTo), ability);
}
@ -162,8 +314,8 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
}
@Override
public TriggeredAbilities copy() {
return new TriggeredAbilities(this);
}
}

View file

@ -269,12 +269,18 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
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);

View file

@ -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<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()
.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()))

View file

@ -820,7 +820,7 @@ public interface Player extends MageItem, Copyable<Player> {
void updateRange(Game game);
ManaOptions getManaAvailable(Game game);
ManaOptions getManaAvailable(Game originalGame);
void addAvailableTriggeredMana(List<Mana> netManaAvailable);
@ -832,7 +832,7 @@ public interface Player extends MageItem, Copyable<Player> {
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);

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.
// 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, Map<MageIdentifier, ManaCosts<ManaCost>>> castSourceIdManaCosts = 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) {
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
List<ActivatedAbility> 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<UUID> needIds = CardUtil.getObjectParts(object);
// 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;
}
@ -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<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(),
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<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<>();
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;
}

View file

@ -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;
}
}