tests: fixed error on load tests end (related to #11572), improved logs, improved session lifecycle on load tests;

tests: added additional test for Mana Maze and deep copy (related to #11572);
docs: added more info to network related code;
This commit is contained in:
Oleg Agafonov 2024-01-08 04:03:16 +04:00
parent 98a3d8b947
commit b3c55555a1
12 changed files with 119 additions and 66 deletions

View file

@ -1329,7 +1329,7 @@ public class NewTournamentDialog extends MageDialog {
tOptions.getMatchOptions().setMatchBufferTime((MatchBufferTime) this.cbBufferTime.getSelectedItem()); tOptions.getMatchOptions().setMatchBufferTime((MatchBufferTime) this.cbBufferTime.getSelectedItem());
tOptions.getMatchOptions().setSkillLevel((SkillLevel) this.cbSkillLevel.getSelectedItem()); tOptions.getMatchOptions().setSkillLevel((SkillLevel) this.cbSkillLevel.getSelectedItem());
tOptions.getMatchOptions().setWinsNeeded((Integer) this.spnNumWins.getValue()); tOptions.getMatchOptions().setWinsNeeded((Integer) this.spnNumWins.getValue());
tOptions.getMatchOptions().setAttackOption(MultiplayerAttackOption.LEFT); tOptions.getMatchOptions().setAttackOption(MultiplayerAttackOption.MULTIPLE);
tOptions.getMatchOptions().setRange(RangeOfInfluence.ALL); tOptions.getMatchOptions().setRange(RangeOfInfluence.ALL);
tOptions.getMatchOptions().setRollbackTurnsAllowed(this.chkRollbackTurnsAllowed.isSelected()); tOptions.getMatchOptions().setRollbackTurnsAllowed(this.chkRollbackTurnsAllowed.isSelected());
tOptions.getMatchOptions().setRated(this.chkRated.isSelected()); tOptions.getMatchOptions().setRated(this.chkRated.isSelected());

View file

@ -59,20 +59,20 @@ public class TableController {
public TableController(ManagerFactory managerFactory, UUID roomId, UUID userId, MatchOptions options) { public TableController(ManagerFactory managerFactory, UUID roomId, UUID userId, MatchOptions options) {
this.managerFactory = managerFactory; this.managerFactory = managerFactory;
timeoutExecutor = managerFactory.threadExecutor().getTimeoutExecutor(); this.timeoutExecutor = managerFactory.threadExecutor().getTimeoutExecutor();
this.userId = userId; this.userId = userId;
this.options = options; this.options = options;
match = GameFactory.instance.createMatch(options.getGameType(), options); this.match = GameFactory.instance.createMatch(options.getGameType(), options);
if (userId != null) { if (userId != null) {
Optional<User> user = managerFactory.userManager().getUser(userId); Optional<User> user = managerFactory.userManager().getUser(userId);
// TODO: Handle if user == null // TODO: Handle if user == null
controllerName = user.map(User::getName).orElse("undefined"); this.controllerName = user.map(User::getName).orElse("undefined");
} else { } else {
controllerName = "System"; this.controllerName = "System";
} }
table = new Table(roomId, options.getGameType(), options.getName(), controllerName, DeckValidatorFactory.instance.createDeckValidator(options.getDeckType()), this.table = new Table(roomId, options.getGameType(), options.getName(), controllerName, DeckValidatorFactory.instance.createDeckValidator(options.getDeckType()),
options.getPlayerTypes(), new TableRecorderImpl(managerFactory.userManager()), match, options.getBannedUsers(), options.isPlaneChase()); options.getPlayerTypes(), new TableRecorderImpl(managerFactory.userManager()), match, options.getBannedUsers(), options.isPlaneChase());
chatId = managerFactory.chatManager().createChatSession("Match Table " + table.getId()); this.chatId = managerFactory.chatManager().createChatSession("Match Table " + table.getId());
init(); init();
} }

View file

@ -1,15 +1,12 @@
package mage.server.game; package mage.server.game;
import mage.MageException; import mage.MageException;
/** /**
*
* @author BetaSteward_at_googlemail.com * @author BetaSteward_at_googlemail.com
*/ */
@FunctionalInterface @FunctionalInterface
public interface GameCallback { public interface GameCallback {
void gameResult(String result) throws MageException; void endGameWithResult(String result) throws MageException;
} }

View file

@ -245,7 +245,7 @@ public class GameController implements GameCallback {
logger.fatal("Send info about player not joined yet:", ex); logger.fatal("Send info about player not joined yet:", ex);
} }
}, GAME_TIMEOUTS_CHECK_JOINING_STATUS_EVERY_SECS, GAME_TIMEOUTS_CHECK_JOINING_STATUS_EVERY_SECS, TimeUnit.SECONDS); }, GAME_TIMEOUTS_CHECK_JOINING_STATUS_EVERY_SECS, GAME_TIMEOUTS_CHECK_JOINING_STATUS_EVERY_SECS, TimeUnit.SECONDS);
checkStart(); checkJoinAndStart();
} }
/** /**
@ -316,7 +316,7 @@ public class GameController implements GameCallback {
user.get().addGame(playerId, gameSession); user.get().addGame(playerId, gameSession);
logger.debug("Player " + player.getName() + ' ' + playerId + " has " + joinType + " gameId: " + game.getId()); logger.debug("Player " + player.getName() + ' ' + playerId + " has " + joinType + " gameId: " + game.getId());
managerFactory.chatManager().broadcast(chatId, "", game.getPlayer(playerId).getLogName() + " has " + joinType + " the game", MessageColor.ORANGE, true, game, MessageType.GAME, null); managerFactory.chatManager().broadcast(chatId, "", game.getPlayer(playerId).getLogName() + " has " + joinType + " the game", MessageColor.ORANGE, true, game, MessageType.GAME, null);
checkStart(); checkJoinAndStart();
} }
private synchronized void startGame() { private synchronized void startGame() {
@ -326,16 +326,19 @@ public class GameController implements GameCallback {
player.updateRange(game); player.updateRange(game);
} }
// send first info to users
for (GameSessionPlayer gameSessionPlayer : getGameSessions()) { for (GameSessionPlayer gameSessionPlayer : getGameSessions()) {
gameSessionPlayer.init(); gameSessionPlayer.init();
} }
// real game start
GameWorker worker = new GameWorker(game, choosingPlayerId, this); GameWorker worker = new GameWorker(game, choosingPlayerId, this);
gameFuture = gameExecutor.submit(worker); gameFuture = gameExecutor.submit(worker);
try { try {
Thread.sleep(1000); Thread.sleep(1000);
} catch (InterruptedException ignore) { } catch (InterruptedException ignore) {
} }
if (game.getState().getChoosingPlayerId() != null) { if (game.getState().getChoosingPlayerId() != null) {
// start timer to force player to choose starting player otherwise loosing by being idle // start timer to force player to choose starting player otherwise loosing by being idle
setupTimeout(game.getState().getChoosingPlayerId()); setupTimeout(game.getState().getChoosingPlayerId());
@ -395,7 +398,7 @@ public class GameController implements GameCallback {
} }
} }
} }
checkStart(); checkJoinAndStart();
} }
private Optional<User> getUserByPlayerId(UUID playerId) { private Optional<User> getUserByPlayerId(UUID playerId) {
@ -407,14 +410,14 @@ public class GameController implements GameCallback {
return Optional.empty(); return Optional.empty();
} }
private void checkStart() { private void checkJoinAndStart() {
if (allJoined()) { if (isAllJoined()) {
joinWaitingExecutor.shutdownNow(); joinWaitingExecutor.shutdownNow();
managerFactory.threadExecutor().getCallExecutor().execute(this::startGame); managerFactory.threadExecutor().getCallExecutor().execute(this::startGame);
} }
} }
private boolean allJoined() { private boolean isAllJoined() {
for (Player player : game.getPlayers().values()) { for (Player player : game.getPlayers().values()) {
if (!player.hasLeft()) { if (!player.hasLeft()) {
Optional<User> user = getUserByPlayerId(player.getId()); Optional<User> user = getUserByPlayerId(player.getId());
@ -480,7 +483,7 @@ public class GameController implements GameCallback {
public void quitMatch(UUID userId) { public void quitMatch(UUID userId) {
UUID playerId = getPlayerId(userId); UUID playerId = getPlayerId(userId);
if (playerId != null) { if (playerId != null) {
if (allJoined()) { if (isAllJoined()) {
GameSessionPlayer gameSessionPlayer = gameSessions.get(playerId); GameSessionPlayer gameSessionPlayer = gameSessions.get(playerId);
if (gameSessionPlayer != null) { if (gameSessionPlayer != null) {
gameSessionPlayer.quitGame(); gameSessionPlayer.quitGame();
@ -492,7 +495,7 @@ public class GameController implements GameCallback {
Player player = game.getPlayer(playerId); Player player = game.getPlayer(playerId);
if (player != null) { if (player != null) {
player.leave(); player.leave();
checkStart(); // => So the game starts and gets an result or multiplayer game starts with active players checkJoinAndStart(); // => So the game starts and gets an result or multiplayer game starts with active players
} }
} }
} }
@ -734,6 +737,7 @@ public class GameController implements GameCallback {
} }
public void endGame(final String message) throws MageException { public void endGame(final String message) throws MageException {
// send end game message/dialog
for (final GameSessionPlayer gameSession : getGameSessions()) { for (final GameSessionPlayer gameSession : getGameSessions()) {
gameSession.gameOver(message); gameSession.gameOver(message);
gameSession.removeGame(); gameSession.removeGame();
@ -741,6 +745,8 @@ public class GameController implements GameCallback {
for (final GameSessionWatcher gameWatcher : getGameSessionWatchers()) { for (final GameSessionWatcher gameWatcher : getGameSessionWatchers()) {
gameWatcher.gameOver(message); gameWatcher.gameOver(message);
} }
// start next game or close finished table
managerFactory.tableManager().endGame(tableId); managerFactory.tableManager().endGame(tableId);
} }
@ -944,7 +950,7 @@ public class GameController implements GameCallback {
} }
@Override @Override
public void gameResult(String result) { public void endGameWithResult(String result) {
try { try {
endGame(result); endGame(result);
} catch (MageException ex) { } catch (MageException ex) {

View file

@ -1,18 +1,18 @@
package mage.server.game; package mage.server.game;
import java.util.UUID;
import java.util.concurrent.Callable;
import mage.MageException; import mage.MageException;
import mage.game.Game; import mage.game.Game;
import org.apache.log4j.Logger; import org.apache.log4j.Logger;
import java.util.UUID;
import java.util.concurrent.Callable;
/** /**
* @param <T> * Game: main thread to process full game (one thread per game)
* @author BetaSteward_at_googlemail.com *
* @author BetaSteward_at_googlemail.com, JayDi85
*/ */
public class GameWorker<T> implements Callable { public class GameWorker implements Callable<Boolean> {
private static final Logger LOGGER = Logger.getLogger(GameWorker.class); private static final Logger LOGGER = Logger.getLogger(GameWorker.class);
@ -27,25 +27,23 @@ public class GameWorker<T> implements Callable {
} }
@Override @Override
public Object call() { public Boolean call() {
try { try {
LOGGER.debug("GAME WORKER started gameId " + game.getId()); // play game
Thread.currentThread().setName("GAME " + game.getId()); Thread.currentThread().setName("GAME " + game.getId());
game.start(choosingPlayerId); game.start(choosingPlayerId);
game.fireUpdatePlayersEvent();
gameController.gameResult(game.getWinner()); // save result and start next game or close finished table
game.cleanUp(); game.fireUpdatePlayersEvent(); // TODO: no needs in update event (gameController.endGameWithResult already send game end dialog)?
} catch (MageException ex) { gameController.endGameWithResult(game.getWinner());
LOGGER.fatal("GameWorker mage error [" + game.getId() + "] " + ex, ex);
} catch (Exception e) { // clear resources
LOGGER.fatal("GameWorker general exception [" + game.getId() + "] " + e.getMessage(), e); game.cleanUp();// TODO: no needs in cleanup code (cards list are useless for memory optimization, game states are more important)?
if (e instanceof NullPointerException) { } catch (MageException e) {
LOGGER.info(e.getStackTrace()); LOGGER.fatal("GameWorker mage error [" + game.getId() + " - " + game + "]: " + e, e);
} } catch (Throwable e) {
} catch (Error err) { LOGGER.fatal("GameWorker system error [" + game.getId() + " - " + game + "]: " + e, e);
LOGGER.fatal("GameWorker general error [" + game.getId() + "] " + err, err);
} }
return null; return null;
} }
} }

View file

@ -32,7 +32,7 @@ public class ReplaySession implements GameCallback {
} }
public void stop() { public void stop() {
gameResult("stopped replay"); endGameWithResult("stopped replay");
} }
public synchronized void next() { public synchronized void next() {
@ -51,7 +51,7 @@ public class ReplaySession implements GameCallback {
} }
@Override @Override
public void gameResult(final String result) { public void endGameWithResult(final String result) {
managerFactory.userManager().getUser(userId).ifPresent(user -> managerFactory.userManager().getUser(userId).ifPresent(user ->
user.fireCallback(new ClientCallback(ClientCallbackMethod.REPLAY_DONE, replay.getGame().getId(), result))); user.fireCallback(new ClientCallback(ClientCallbackMethod.REPLAY_DONE, replay.getGame().getId(), result)));
@ -60,7 +60,7 @@ public class ReplaySession implements GameCallback {
private void updateGame(final GameState state, Game game) { private void updateGame(final GameState state, Game game) {
if (state == null) { if (state == null) {
gameResult("game ended"); endGameWithResult("game ended");
} else { } else {
managerFactory.userManager().getUser(userId).ifPresent(user -> managerFactory.userManager().getUser(userId).ifPresent(user ->
user.fireCallback(new ClientCallback(ClientCallbackMethod.REPLAY_UPDATE, replay.getGame().getId(), new GameView(state, game, null, null)))); user.fireCallback(new ClientCallback(ClientCallbackMethod.REPLAY_UPDATE, replay.getGame().getId(), new GameView(state, game, null, null))));

View file

@ -1,20 +1,34 @@
package mage.server.managers; package mage.server.managers;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
/**
* Server side threads to execute some code/tasks
*/
public interface ThreadExecutor { public interface ThreadExecutor {
int getActiveThreads(ExecutorService executerService); int getActiveThreads(ExecutorService executerService);
ExecutorService getCallExecutor(); /**
* Main game thread (one per game)
*/
ExecutorService getGameExecutor(); ExecutorService getGameExecutor();
/**
* Helper threads to execute async commands for game and server related tasks (example: process income command from a client)
*/
ExecutorService getCallExecutor();
/**
* Helper threads to execute async timers and time related tasks
*/
ScheduledExecutorService getTimeoutExecutor(); ScheduledExecutorService getTimeoutExecutor();
ScheduledExecutorService getTimeoutIdleExecutor(); ScheduledExecutorService getTimeoutIdleExecutor();
/**
* Helper thread to execute inner server tasks
*/
ScheduledExecutorService getServerHealthExecutor(); ScheduledExecutorService getServerHealthExecutor();
} }

View file

@ -2,19 +2,48 @@ package org.mage.test.cards.single.inv;
import mage.constants.PhaseStep; import mage.constants.PhaseStep;
import mage.constants.Zone; import mage.constants.Zone;
import mage.util.CardUtil;
import org.junit.Assert;
import org.junit.Ignore; import org.junit.Ignore;
import org.junit.Test; import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase; import org.mage.test.serverside.base.CardTestPlayerBase;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** /**
* @author JayDi85 * @author JayDi85
*/ */
@Ignore // TODO: enable after deep copy fix
public class ManaMazeTest extends CardTestPlayerBase { public class ManaMazeTest extends CardTestPlayerBase {
@Test @Test
@Ignore // TODO: enable after deep copy fix public void test_DeepCopy_WithSelfReference() {
public void test_DeepCopyWithWatcherAndSelfReference() { // stack overflow bug: https://github.com/magefree/mage/issues/11572
// list
List<String> sourceList = new ArrayList<>(Arrays.asList("val1", "val2", "val3"));
List<String> copyList = CardUtil.deepCopyObject(sourceList);
Assert.assertNotSame(sourceList, copyList);
Assert.assertEquals(sourceList.size(), copyList.size());
Assert.assertEquals(sourceList.toString(), copyList.toString());
// list with self ref
List<List<Object>> sourceObjectList = new ArrayList<>();
sourceObjectList.add(new ArrayList<>(Arrays.asList("val1", "val2", "val3")));
sourceObjectList.add(new ArrayList<>(Arrays.asList(sourceObjectList)));
List<List<Object>> copyObjectList = CardUtil.deepCopyObject(sourceObjectList);
Assert.assertNotSame(sourceObjectList, copyObjectList);
Assert.assertEquals(sourceObjectList.size(), copyObjectList.size());
Assert.assertEquals(sourceObjectList.get(0).size(), copyObjectList.get(0).size());
Assert.assertEquals(sourceObjectList.get(0).toString(), copyObjectList.get(0).toString());
Assert.assertEquals(sourceObjectList.get(1).size(), copyObjectList.get(1).size());
Assert.assertEquals(sourceObjectList.get(1).toString(), copyObjectList.get(1).toString());
}
@Test
public void test_DeepCopy_WatcherWithSelfReference() {
// stack overflow bug: https://github.com/magefree/mage/issues/11572 // stack overflow bug: https://github.com/magefree/mage/issues/11572
// card's watcher can have spell's ref to itself, so deep copy must be able to process it // card's watcher can have spell's ref to itself, so deep copy must be able to process it

View file

@ -200,7 +200,7 @@ public class LoadTest {
// playing until game over // playing until game over
while (!player1.client.isGameOver() && !player2.client.isGameOver()) { while (!player1.client.isGameOver() && !player2.client.isGameOver()) {
checkGame = monitor.getTable(tableId); checkGame = monitor.getTable(tableId);
logger.warn(checkGame.get().getTableState()); logger.info(checkGame.get().getTableState());
try { try {
Thread.sleep(1000); Thread.sleep(1000);
} catch (InterruptedException e) { } catch (InterruptedException e) {
@ -236,6 +236,7 @@ public class LoadTest {
// playing until game over // playing until game over
gameResult.start(); gameResult.start();
boolean startToWatching = false; boolean startToWatching = false;
Date lastActivity = new Date();
while (true) { while (true) {
GameView gameView = monitor.client.getLastGameView(); GameView gameView = monitor.client.getLastGameView();
@ -243,8 +244,8 @@ public class LoadTest {
TableState state = (checkGame == null ? null : checkGame.getTableState()); TableState state = (checkGame == null ? null : checkGame.getTableState());
if (gameView != null && checkGame != null) { if (gameView != null && checkGame != null) {
logger.warn(checkGame.getTableName() + ": ---"); logger.info(checkGame.getTableName() + ": ---");
logger.warn(String.format("%s: turn %d, step %s, state %s", logger.info(String.format("%s: turn %d, step %s, state %s",
checkGame.getTableName(), checkGame.getTableName(),
gameView.getTurn(), gameView.getTurn(),
gameView.getStep().toString(), gameView.getStep().toString(),
@ -279,6 +280,13 @@ public class LoadTest {
activeInfo activeInfo
)); ));
}); });
logger.info(checkGame.getTableName() + ": ---");
}
// ping to keep active session
if ((new Date().getTime() - lastActivity.getTime()) / 1000 > 10) {
monitor.session.ping();
lastActivity = new Date();
} }
try { try {
@ -287,6 +295,9 @@ public class LoadTest {
logger.error(e.getMessage(), e); logger.error(e.getMessage(), e);
} }
} }
// all done, can disconnect now
monitor.session.connectStop(false, false);
} }
@Test @Test

View file

@ -3973,10 +3973,11 @@ public abstract class GameImpl implements Game {
Player activePayer = this.getPlayer(this.getActivePlayerId()); Player activePayer = this.getPlayer(this.getActivePlayerId());
StringBuilder sb = new StringBuilder() StringBuilder sb = new StringBuilder()
.append(this.isSimulation() ? "!!!SIMULATION!!! " : "") .append(this.isSimulation() ? "!!!SIMULATION!!! " : "")
.append(this.getGameType().toString()) .append("; ").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()))
.append("; stack: ").append(this.getStack().toString()); .append("; stack: ").append(this.getStack().toString())
.append(this.getState().isGameOver() ? "; FINISHED: " + this.getWinner() : "");
return sb.toString(); return sb.toString();
} }
} }

View file

@ -1,4 +1,3 @@
package mage.game.match; package mage.game.match;
import mage.cards.decks.DeckCardInfo; import mage.cards.decks.DeckCardInfo;
@ -20,8 +19,8 @@ import java.util.*;
public class MatchOptions implements Serializable { public class MatchOptions implements Serializable {
protected String name; protected String name;
protected MultiplayerAttackOption attackOption; protected MultiplayerAttackOption attackOption = MultiplayerAttackOption.LEFT;
protected RangeOfInfluence range; protected RangeOfInfluence range = RangeOfInfluence.ALL;
protected int winsNeeded; protected int winsNeeded;
protected int freeMulligans; protected int freeMulligans;
protected boolean customStartLifeEnabled; protected boolean customStartLifeEnabled;
@ -35,7 +34,7 @@ public class MatchOptions implements Serializable {
protected boolean multiPlayer; protected boolean multiPlayer;
protected int numSeats; protected int numSeats;
protected String password; protected String password;
protected SkillLevel skillLevel; protected SkillLevel skillLevel = SkillLevel.CASUAL;
protected boolean rollbackTurnsAllowed; protected boolean rollbackTurnsAllowed;
protected boolean spectatorsAllowed; protected boolean spectatorsAllowed;
protected boolean planeChase; protected boolean planeChase;
@ -49,8 +48,8 @@ public class MatchOptions implements Serializable {
protected MatchBufferTime matchBufferTime = MatchBufferTime.NONE; // additional/buffer time limit for each priority before real time ticking starts protected MatchBufferTime matchBufferTime = MatchBufferTime.NONE; // additional/buffer time limit for each priority before real time ticking starts
protected MulliganType mulliganType = MulliganType.GAME_DEFAULT; protected MulliganType mulliganType = MulliganType.GAME_DEFAULT;
protected Collection<DeckCardInfo> perPlayerEmblemCards; protected Collection<DeckCardInfo> perPlayerEmblemCards = Collections.emptySet();
protected Collection<DeckCardInfo> globalEmblemCards; protected Collection<DeckCardInfo> globalEmblemCards = Collections.emptySet();
public MatchOptions(String name, String gameType, boolean multiPlayer, int numSeats) { public MatchOptions(String name, String gameType, boolean multiPlayer, int numSeats) {
this.name = name; this.name = name;
@ -58,8 +57,6 @@ public class MatchOptions implements Serializable {
this.password = ""; this.password = "";
this.multiPlayer = multiPlayer; this.multiPlayer = multiPlayer;
this.numSeats = numSeats; this.numSeats = numSeats;
this.perPlayerEmblemCards = Collections.emptySet();
this.globalEmblemCards = Collections.emptySet();
} }
public void setNumSeats(int numSeats) { public void setNumSeats(int numSeats) {

View file

@ -75,7 +75,7 @@ Github issues page contain [popular problems and fixes](https://github.com/magef
* [Any: can't run client, could not open ...jvm.cfg](https://github.com/magefree/mage/issues/1272#issuecomment-529789018); * [Any: can't run client, could not open ...jvm.cfg](https://github.com/magefree/mage/issues/1272#issuecomment-529789018);
* [Any: no texts or small buttons in launcher](https://github.com/magefree/mage/issues/4126); * [Any: no texts or small buttons in launcher](https://github.com/magefree/mage/issues/4126);
* [Windows: ugly cards, buttons or other GUI drawing artifacts](https://github.com/magefree/mage/issues/4626#issuecomment-374640070); * [Windows: ugly cards, buttons or other GUI drawing artifacts](https://github.com/magefree/mage/issues/4626#issuecomment-374640070);
* [MacOS: can't open launcher](https://www.reddit.com/r/XMage/comments/kf8l34/updated_java_on_osx_xmage_not_working/ggej8cq/) * [MacOS: can't open launcher](https://www.reddit.com/r/XMage/comments/kf8l34/updated_java_on_osx_xmage_not_working/ggej8cq/);
* [MacOS: client freezes in GUI (on connect dialog, on new match)](https://github.com/magefree/mage/issues/4920#issuecomment-517944308); * [MacOS: client freezes in GUI (on connect dialog, on new match)](https://github.com/magefree/mage/issues/4920#issuecomment-517944308);
* [Linux: run on non-standard OS or hardware like Raspberry Pi](https://github.com/magefree/mage/issues/11611#issuecomment-1879385151); * [Linux: run on non-standard OS or hardware like Raspberry Pi](https://github.com/magefree/mage/issues/11611#issuecomment-1879385151);
* [Linux: ugly GUI and drawing artifacts](https://github.com/magefree/mage/issues/11611#issuecomment-1879396921); * [Linux: ugly GUI and drawing artifacts](https://github.com/magefree/mage/issues/11611#issuecomment-1879396921);