diff --git a/.gitignore b/.gitignore index d35b250f77b..f1052671c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ syntax: glob *.log.* */gamelogs */gamelogsJson +*/gamesHistory # Mage.Client Mage.Client/plugins/images diff --git a/.travis.yml b/.travis.yml index f665f8924cd..91b52ed33ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ git: before_install: - echo "MAVEN_OPTS='-Xmx2g'" > ~/.mavenrc script: - - mvn test -B -Dlog4j.configuration=file:${TRAVIS_BUILD_DIR}/.travis/log4j.properties + - mvn test -B -Dxmage.dataCollectors.printGameLogs=false -Dlog4j.configuration=file:${TRAVIS_BUILD_DIR}/.travis/log4j.properties cache: directories: - $HOME/.m2 \ No newline at end of file diff --git a/Mage.Client/src/main/java/mage/client/draft/DraftPickLogger.java b/Mage.Client/src/main/java/mage/client/draft/DraftPickLogger.java index db585e1a227..90c094fd055 100644 --- a/Mage.Client/src/main/java/mage/client/draft/DraftPickLogger.java +++ b/Mage.Client/src/main/java/mage/client/draft/DraftPickLogger.java @@ -75,7 +75,7 @@ public class DraftPickLogger { private void appendToDraftLog(String data) { if (logging) { try { - Files.write(logPath, data.getBytes(), StandardOpenOption.APPEND); + Files.write(logPath, data.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException ex) { LOGGER.error(null, ex); } diff --git a/Mage.Common/pom.xml b/Mage.Common/pom.xml index 36fe0dc82ca..d2935eb53cc 100644 --- a/Mage.Common/pom.xml +++ b/Mage.Common/pom.xml @@ -66,7 +66,6 @@ org.apache.commons commons-lang3 - test diff --git a/Mage.Common/src/main/java/mage/view/ChatMessage.java b/Mage.Common/src/main/java/mage/view/ChatMessage.java index adf541553cb..02a5b6db31c 100644 --- a/Mage.Common/src/main/java/mage/view/ChatMessage.java +++ b/Mage.Common/src/main/java/mage/view/ChatMessage.java @@ -25,7 +25,12 @@ public class ChatMessage implements Serializable { } public enum MessageType { - USER_INFO, STATUS, GAME, TALK, WHISPER_FROM, WHISPER_TO + USER_INFO, // system messages + STATUS, // system messages + GAME, // game logs + TALK, // public chat + WHISPER_FROM, // private chat income + WHISPER_TO // private chat outcome } public enum SoundToPlay { diff --git a/Mage.Server/pom.xml b/Mage.Server/pom.xml index 2acdc206ab3..12051c74d86 100644 --- a/Mage.Server/pom.xml +++ b/Mage.Server/pom.xml @@ -192,11 +192,6 @@ runtime - - org.apache.commons - commons-lang3 - 3.11 - javax.xml.bind jaxb-api diff --git a/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java b/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java index de1c4e24081..335dc081dc0 100644 --- a/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java +++ b/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java @@ -4,6 +4,8 @@ import mage.cards.repository.CardInfo; import mage.cards.repository.CardRepository; import mage.constants.Constants; import mage.game.Game; +import mage.game.Table; +import mage.game.tournament.Tournament; import mage.server.game.GameController; import mage.server.managers.ChatManager; import mage.server.managers.ManagerFactory; @@ -40,11 +42,39 @@ public class ChatManagerImpl implements ChatManager { this.managerFactory = managerFactory; } + @Override - public UUID createChatSession(String info) { + public UUID createRoomChatSession(UUID roomId) { + return createChatSession("Room " + roomId) + .withRoom(roomId) + .getChatId(); + } + + @Override + public UUID createTourneyChatSession(Tournament tournament) { + return createChatSession("Tourney " + tournament.getId()) + .withTourney(tournament) + .getChatId(); + } + + @Override + public UUID createTableChatSession(Table table) { + return createChatSession("Table " + table.getId()) + .withTable(table) + .getChatId(); + } + + @Override + public UUID createGameChatSession(Game game) { + return createChatSession("Game " + game.getId()) + .withGame(game) + .getChatId(); + } + + private ChatSession createChatSession(String info) { ChatSession chatSession = new ChatSession(managerFactory, info); chatSessions.put(chatSession.getChatId(), chatSession); - return chatSession.getChatId(); + return chatSession; } @Override @@ -94,7 +124,7 @@ public class ChatManagerImpl implements ChatManager { ChatSession chatSession = chatSessions.get(chatId); Optional user = managerFactory.userManager().getUserByName(userName); if (chatSession != null) { - // special commads + // special commands if (message.startsWith("\\") || message.startsWith("/")) { if (user.isPresent()) { if (!performUserCommand(user.get(), message, chatId, false)) { diff --git a/Mage.Server/src/main/java/mage/server/ChatSession.java b/Mage.Server/src/main/java/mage/server/ChatSession.java index b58222afbfb..229ee885b6f 100644 --- a/Mage.Server/src/main/java/mage/server/ChatSession.java +++ b/Mage.Server/src/main/java/mage/server/ChatSession.java @@ -1,6 +1,9 @@ package mage.server; +import mage.collectors.DataCollectorServices; import mage.game.Game; +import mage.game.Table; +import mage.game.tournament.Tournament; import mage.interfaces.callback.ClientCallback; import mage.interfaces.callback.ClientCallbackMethod; import mage.server.managers.ManagerFactory; @@ -27,6 +30,13 @@ public class ChatSession { private final ManagerFactory managerFactory; private final ReadWriteLock lock = new ReentrantReadWriteLock(); // TODO: no needs due ConcurrentHashMap usage? + // only 1 field must be filled per chat type + // TODO: rework chat sessions to share logic (one server room/lobby + one table/subtable + one games/match) + private UUID roomId = null; + private UUID tourneyId = null; + private UUID tableId = null; + private UUID gameId = null; + private final ConcurrentMap users = new ConcurrentHashMap<>(); // active users private final Set usersHistory = new HashSet<>(); // all users that was here (need for system messages like connection problem) private final UUID chatId; @@ -40,6 +50,26 @@ public class ChatSession { this.info = info; } + public ChatSession withRoom(UUID roomId) { + this.roomId = roomId; + return this; + } + + public ChatSession withTourney(Tournament tournament) { + this.tourneyId = tournament.getId(); + return this; + } + + public ChatSession withTable(Table table) { + this.tableId = table.getId(); + return this; + } + + public ChatSession withGame(Game game) { + this.gameId = game.getId(); + return this; + } + public void join(UUID userId) { managerFactory.userManager().getUser(userId).ifPresent(user -> { if (!users.containsKey(userId)) { @@ -112,9 +142,36 @@ public class ChatSession { // TODO: is it freeze on someone's connection fail/freeze with play multiple games/chats/lobby? // TODO: send messages in another thread?! if (!message.isEmpty()) { + ChatMessage chatMessage = new ChatMessage(userName, message, (withTime ? new Date() : null), game, color, messageType, soundToPlay); + + switch (messageType) { + case USER_INFO: + case STATUS: + case TALK: + if (this.roomId != null) { + DataCollectorServices.getInstance().onChatRoom(this.roomId, userName, message); + } else if (this.tourneyId != null) { + DataCollectorServices.getInstance().onChatTourney(this.tourneyId, userName, message); + } else if (this.tableId != null) { + DataCollectorServices.getInstance().onChatTable(this.tableId, userName, message); + } else if (this.gameId != null) { + DataCollectorServices.getInstance().onChatGame(this.gameId, userName, message); + } + break; + case GAME: + // game logs processing in other place + break; + case WHISPER_FROM: + case WHISPER_TO: + // ignore private messages + break; + default: + throw new IllegalStateException("Unsupported message type " + messageType); + } + + // TODO: wtf, remove all that locks/tries and make it simpler Set clientsToRemove = new HashSet<>(); - ClientCallback clientCallback = new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, - new ChatMessage(userName, message, (withTime ? new Date() : null), game, color, messageType, soundToPlay)); + ClientCallback clientCallback = new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, chatMessage); List chatUserIds = new ArrayList<>(); final Lock r = lock.readLock(); r.lock(); diff --git a/Mage.Server/src/main/java/mage/server/MageServerImpl.java b/Mage.Server/src/main/java/mage/server/MageServerImpl.java index dcc20c38256..a0083afc710 100644 --- a/Mage.Server/src/main/java/mage/server/MageServerImpl.java +++ b/Mage.Server/src/main/java/mage/server/MageServerImpl.java @@ -5,6 +5,7 @@ import mage.cards.decks.DeckCardLists; import mage.cards.decks.DeckValidatorFactory; import mage.cards.repository.CardRepository; import mage.cards.repository.ExpansionRepository; +import mage.collectors.DataCollectorServices; import mage.constants.Constants; import mage.constants.ManaType; import mage.constants.PlayerAction; @@ -29,6 +30,7 @@ import mage.server.managers.ManagerFactory; import mage.server.services.impl.FeedbackServiceImpl; import mage.server.tournament.TournamentFactory; import mage.server.util.ServerMessagesUtil; +import mage.util.DebugUtil; import mage.utils.*; import mage.view.*; import mage.view.ChatMessage.MessageColor; @@ -68,6 +70,13 @@ public class MageServerImpl implements MageServer { this.detailsMode = detailsMode; this.callExecutor = managerFactory.threadExecutor().getCallExecutor(); ServerMessagesUtil.instance.getMessages(); + + // additional logs + DataCollectorServices.init( + DebugUtil.SERVER_DATA_COLLECTORS_ENABLE_PRINT_GAME_LOGS, + DebugUtil.SERVER_DATA_COLLECTORS_ENABLE_SAVE_GAME_HISTORY + ); + DataCollectorServices.getInstance().onServerStart(); } @Override diff --git a/Mage.Server/src/main/java/mage/server/RoomImpl.java b/Mage.Server/src/main/java/mage/server/RoomImpl.java index 9221ee0cd38..8b628544e27 100644 --- a/Mage.Server/src/main/java/mage/server/RoomImpl.java +++ b/Mage.Server/src/main/java/mage/server/RoomImpl.java @@ -14,7 +14,7 @@ public abstract class RoomImpl implements Room { public RoomImpl(ChatManager chatManager) { roomId = UUID.randomUUID(); - chatId = chatManager.createChatSession("Room " + roomId); + chatId = chatManager.createRoomChatSession(roomId); } /** diff --git a/Mage.Server/src/main/java/mage/server/TableController.java b/Mage.Server/src/main/java/mage/server/TableController.java index b23895416cd..be338040777 100644 --- a/Mage.Server/src/main/java/mage/server/TableController.java +++ b/Mage.Server/src/main/java/mage/server/TableController.java @@ -72,7 +72,7 @@ public class TableController { } 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()); - this.chatId = managerFactory.chatManager().createChatSession("Match Table " + table.getId()); + this.chatId = managerFactory.chatManager().createTableChatSession(table); init(); } @@ -94,7 +94,7 @@ public class TableController { } table = new Table(roomId, options.getTournamentType(), options.getName(), controllerName, DeckValidatorFactory.instance.createDeckValidator(options.getMatchOptions().getDeckType()), options.getPlayerTypes(), new TableRecorderImpl(managerFactory.userManager()), tournament, options.getMatchOptions().getBannedUsers(), options.isPlaneChase()); - chatId = managerFactory.chatManager().createChatSession("Tourney table " + table.getId()); + chatId = managerFactory.chatManager().createTableChatSession(table); } private void init() { diff --git a/Mage.Server/src/main/java/mage/server/game/GameController.java b/Mage.Server/src/main/java/mage/server/game/GameController.java index 96ed8b53505..8141658f86f 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameController.java +++ b/Mage.Server/src/main/java/mage/server/game/GameController.java @@ -85,11 +85,11 @@ public class GameController implements GameCallback { public GameController(ManagerFactory managerFactory, Game game, ConcurrentMap userPlayerMap, UUID tableId, UUID choosingPlayerId, GameOptions gameOptions) { this.managerFactory = managerFactory; - gameExecutor = managerFactory.threadExecutor().getGameExecutor(); - responseIdleTimeoutExecutor = managerFactory.threadExecutor().getTimeoutIdleExecutor(); - gameSessionId = UUID.randomUUID(); + this.gameExecutor = managerFactory.threadExecutor().getGameExecutor(); + this.responseIdleTimeoutExecutor = managerFactory.threadExecutor().getTimeoutIdleExecutor(); + this.gameSessionId = UUID.randomUUID(); this.userPlayerMap = userPlayerMap; - chatId = managerFactory.chatManager().createChatSession("Game " + game.getId()); + this.chatId = managerFactory.chatManager().createGameChatSession(game); this.userRequestingRollback = null; this.game = game; this.game.setSaveGame(managerFactory.configSettings().isSaveGameActivated()); diff --git a/Mage.Server/src/main/java/mage/server/managers/ChatManager.java b/Mage.Server/src/main/java/mage/server/managers/ChatManager.java index bf9fcb0e0dd..056500cae69 100644 --- a/Mage.Server/src/main/java/mage/server/managers/ChatManager.java +++ b/Mage.Server/src/main/java/mage/server/managers/ChatManager.java @@ -1,6 +1,8 @@ package mage.server.managers; import mage.game.Game; +import mage.game.Table; +import mage.game.tournament.Tournament; import mage.server.ChatSession; import mage.server.DisconnectReason; import mage.view.ChatMessage; @@ -10,7 +12,13 @@ import java.util.UUID; public interface ChatManager { - UUID createChatSession(String info); + UUID createRoomChatSession(UUID roomId); + + UUID createTourneyChatSession(Tournament tournament); + + UUID createTableChatSession(Table table); + + UUID createGameChatSession(Game game); void joinChat(UUID chatId, UUID userId); diff --git a/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java b/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java index 939ee2db5b9..df85a172b05 100644 --- a/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java +++ b/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java @@ -54,7 +54,7 @@ public class TournamentController { public TournamentController(ManagerFactory managerFactory, Tournament tournament, ConcurrentMap userPlayerMap, UUID tableId) { this.managerFactory = managerFactory; this.userPlayerMap = userPlayerMap; - chatId = managerFactory.chatManager().createChatSession("Tournament " + tournament.getId()); + this.chatId = managerFactory.chatManager().createTourneyChatSession(tournament); this.tournament = tournament; this.tableId = tableId; init(); @@ -261,9 +261,7 @@ public class TournamentController { table.setState(TableState.STARTING); tableManager.startTournamentSubMatch(null, table.getId()); tableManager.getMatch(table.getId()).ifPresent(match -> { - match.setTableId(tableId); - pair.setMatch(match); - pair.setTableId(table.getId()); + pair.setMatchAndTable(match, table.getId()); player1.setState(TournamentPlayerState.DUELING); player2.setState(TournamentPlayerState.DUELING); }); @@ -291,9 +289,7 @@ public class TournamentController { table.setState(TableState.STARTING); tableManager.startTournamentSubMatch(null, table.getId()); tableManager.getMatch(table.getId()).ifPresent(match -> { - match.setTableId(tableId); - round.setMatch(match); - round.setTableId(table.getId()); + round.setMatchAndTable(match, table.getId()); for (TournamentPlayer player : round.getAllPlayers()) { player.setState(TournamentPlayerState.DUELING); } diff --git a/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java b/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java index e9965808b0f..eec1b306034 100644 --- a/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java @@ -54,8 +54,8 @@ public class LoadTest { private static final String TEST_AI_RANDOM_DECK_SETS = ""; // sets list for random generated decks (GRN,ACR for specific sets, empty for all sets, PELP for lands only - communication test) private static final String TEST_AI_RANDOM_DECK_COLORS_FOR_EMPTY_GAME = "GR"; // colors list for deck generation, empty for all colors private static final String TEST_AI_RANDOM_DECK_COLORS_FOR_AI_GAME = "WUBRG"; - private static final String TEST_AI_CUSTOM_DECK_PATH_1 = ""; // custom deck file instead random for player 1 (empty for random) - private static final String TEST_AI_CUSTOM_DECK_PATH_2 = ""; // custom deck file instead random for player 2 (empty for random) + private static String TEST_AI_CUSTOM_DECK_PATH_1 = ""; // custom deck file instead random for player 1 (empty for random) + private static String TEST_AI_CUSTOM_DECK_PATH_2 = ""; // custom deck file instead random for player 2 (empty for random) @BeforeClass public static void initDatabase() { @@ -150,6 +150,10 @@ public class LoadTest { Thread.sleep(minimumSleepTime); Assert.assertEquals("Can't see users count change 2", startUsersCount + 2, monitor.getAllRoomUsers().size()); Assert.assertNotNull("Can't find user 2", monitor.findUser(player2.userName)); + + player1.disconnect(); + player2.disconnect(); + monitor.disconnect(); } @Test @@ -213,6 +217,10 @@ public class LoadTest { logger.error(e.getMessage(), e); } } + + player1.disconnect(); + player2.disconnect(); + monitor.disconnect(); } public void playTwoAIGame(String gameName, Integer taskNumber, TasksProgress tasksProgress, long randomSeed, String deckColors, String deckAllowedSets, LoadTestGameResult gameResult) { @@ -251,12 +259,21 @@ public class LoadTest { String finishInfo = ""; if (state == TableState.FINISHED) { - finishInfo = gameView == null ? "??" : gameView.getStep().getStepShortText().toLowerCase(Locale.ENGLISH); + finishInfo = gameView == null || gameView.getStep() == null ? "??" : gameView.getStep().getStepShortText().toLowerCase(Locale.ENGLISH); } tasksProgress.update(taskNumber, finishInfo, gameView == null ? 0 : gameView.getTurn()); String globalProgress = tasksProgress.getInfo(); monitor.client.updateGlobalProgress(globalProgress); + // disconnected by unknown reason TODO: research the reason + if (!monitor.session.isConnected()) { + logger.error(monitor.userName + " disconnected from server on game " + gameView); + if (gameView != null) { + gameResult.finish(gameView); + } + break; + } + if (gameView != null && checkGame != null) { logger.info(globalProgress + ", " + checkGame.getTableName() + ": ---"); logger.info(String.format("%s, %s: turn %d, step %s, state %s", @@ -314,7 +331,7 @@ public class LoadTest { } // all done, can disconnect now - monitor.session.connectStop(false, false); + monitor.disconnect(); } @Test @@ -330,6 +347,31 @@ public class LoadTest { printGameResults(gameResults); } + @Test + @Ignore + public void test_TwoAIPlayGame_Debug() { + // usage: + // - run test_TwoAIPlayGame_Multiple + // - wait some freeze games and stop + // - find active table and game in server's games history + // - set deck files here + // - run single game and debug on server side like stack dump + long randomSeed = 1708140198; // 0 for random + TEST_AI_CUSTOM_DECK_PATH_1 = ".\\deck_player_1.dck"; + TEST_AI_CUSTOM_DECK_PATH_2 = ".\\deck_player_2.dck"; + + LoadTestGameResultsList gameResults = new LoadTestGameResultsList(); + if (randomSeed == 0) { + randomSeed = RandomUtil.nextInt(); + } + LoadTestGameResult gameResult = gameResults.createGame(0, "test game", randomSeed); + TasksProgress tasksProgress = new TasksProgress(); + tasksProgress.update(1, "", 0); + playTwoAIGame("Single AI game", 1, tasksProgress, randomSeed, "WGUBR", TEST_AI_RANDOM_DECK_SETS, gameResult); + + printGameResults(gameResults); + } + @Test @Ignore public void test_TwoAIPlayGame_Multiple() { @@ -673,7 +715,8 @@ public class LoadTest { this.client.setSession(this.session); this.roomID = this.session.getMainRoomId(); - Assert.assertTrue("client must be connected to server", this.session.isServerReady()); + Assert.assertTrue("client must be connected to server", this.session.isConnected()); + Assert.assertTrue("client must get server data", this.session.isServerReady()); } public ArrayList getAllRoomUsers() { 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 a0c6cf587e1..436daf44d55 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 @@ -13,6 +13,7 @@ import mage.cards.decks.importer.DeckImporter; import mage.cards.repository.CardInfo; import mage.cards.repository.CardRepository; import mage.cards.repository.CardScanner; +import mage.collectors.DataCollectorServices; import mage.constants.*; import mage.counters.CounterType; import mage.filter.Filter; @@ -30,6 +31,7 @@ import mage.players.ManaPool; import mage.players.Player; import mage.server.game.GameSessionPlayer; import mage.util.CardUtil; +import mage.util.DebugUtil; import mage.util.ThreadUtils; import mage.utils.SystemUtil; import mage.view.GameView; @@ -244,6 +246,11 @@ public abstract class CardTestPlayerAPIImpl extends MageTestPlayerBase implement ThreadUtils.ensureRunInGameThread(); + DataCollectorServices.init( + true, + DebugUtil.TESTS_DATA_COLLECTORS_ENABLE_SAVE_GAME_HISTORY + ); + // check stop command int maxTurn = 1; int maxPhase = 0; diff --git a/Mage/pom.xml b/Mage/pom.xml index cd79961ea6b..8759c48b385 100644 --- a/Mage/pom.xml +++ b/Mage/pom.xml @@ -22,6 +22,10 @@ com.google.code.gson gson + + org.jsoup + jsoup + com.google.protobuf protobuf-java @@ -42,34 +46,34 @@ - com.github.os72 protoc-jar-maven-plugin 3.11.4 - generate-sources - - run - - - - com.google.protobuf:protoc:3.25.8 - - ${project.basedir}/src/main/proto - - - - java - none - ${project.build.directory}/generated-sources - - - + generate-sources + + run + + + + com.google.protobuf:protoc:3.25.8 + + ${project.basedir}/src/main/proto + + + + java + none + ${project.build.directory}/generated-sources + + + + org.codehaus.mojo @@ -89,12 +93,10 @@ - + mage - - diff --git a/Mage/src/main/java/mage/collectors/DataCollector.java b/Mage/src/main/java/mage/collectors/DataCollector.java new file mode 100644 index 00000000000..db3471a4acd --- /dev/null +++ b/Mage/src/main/java/mage/collectors/DataCollector.java @@ -0,0 +1,68 @@ +package mage.collectors; + +import mage.game.Game; +import mage.game.Table; + +import java.util.UUID; + +/** + * Data collection for better debugging. Can collect server/table/game events and process related data. + *

+ * Supported features: + * - [x] collect and print game logs in server output, including unit tests + * - [x] collect and save full games history and decks + * - [ ] collect and print performance metrics like ApplyEffects calc time or inform players time (pings) + * - [ ] collect and send metrics to third party tools like prometheus + grafana + * - [ ] prepare "attachable" game data for bug reports + * - [ ] record game replays data (GameView history) + *

+ * How-to enable or disable: + * - use java params like -Dxmage.dataCollectors.saveGameHistory=true + *

+ * How-to add new service: + * - create new class and extends EmptyDataCollector + * - each service must use unique service code + * - override only needed events + * - modify DataCollectorServices.init with new class + * - make sure it's fast and never raise errors + * + * @author JayDi85 + */ +public interface DataCollector { + + /** + * Return unique service code to enable by command line + */ + String getServiceCode(); + + /** + * Show some hints on service enabled, e.g. root folder path + */ + String getInitInfo(); + + void onServerStart(); + + void onTableStart(Table table); + + void onTableEnd(Table table); + + void onGameStart(Game game); + + void onGameLog(Game game, String message); + + void onGameEnd(Game game); + + /** + * @param userName can be null for system messages + */ + void onChatRoom(UUID roomId, String userName, String message); + + void onChatTourney(UUID tourneyId, String userName, String message); + + void onChatTable(UUID tableId, String userName, String message); + + /** + * @param gameId chat sessings don't have full game access, so use onGameStart event to find game's ID before chat + */ + void onChatGame(UUID gameId, String userName, String message); +} diff --git a/Mage/src/main/java/mage/collectors/DataCollectorServices.java b/Mage/src/main/java/mage/collectors/DataCollectorServices.java new file mode 100644 index 00000000000..dc2740f078a --- /dev/null +++ b/Mage/src/main/java/mage/collectors/DataCollectorServices.java @@ -0,0 +1,143 @@ +package mage.collectors; + +import mage.collectors.services.PrintGameLogsDataCollector; +import mage.collectors.services.SaveGameHistoryDataCollector; +import mage.game.Game; +import mage.game.Table; +import org.apache.log4j.Logger; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Not a real data collector. It's a global service to inject and collect data all around the code. + * + * @author JayDi85 + */ +final public class DataCollectorServices implements DataCollector { + + // usage example: -Dxmage.dataCollectors.saveGameHistory=true + private static final String COMMAND_LINE_DATA_COLLECTORS_PREFIX = "xmage.dataCollectors."; + + private static final Logger logger = Logger.getLogger(DataCollectorServices.class); + + private static DataCollectorServices instance = null; + + // fill on server startup, so it's thread safe + Set allServices = new LinkedHashSet<>(); + Set activeServices = new LinkedHashSet<>(); + + public static DataCollectorServices getInstance() { + if (instance == null) { + instance = new DataCollectorServices(); + } + return instance; + } + + /** + * Init data service on server's startup + * + * @param enablePrintGameLogs use for unit tests to enable additional logs for better debugging + * @param enableSaveGameHistory use to save full game history with logs, decks, etc + */ + public static void init(boolean enablePrintGameLogs, boolean enableSaveGameHistory) { + if (instance != null) { + // unit tests: init on first test run, all other will use same process + // real server: init on server startup + return; + } + + // fill all possible services + getInstance().allServices.add(new PrintGameLogsDataCollector()); + getInstance().allServices.add(new SaveGameHistoryDataCollector()); + logger.info(String.format("Data collectors: found %d services", getInstance().allServices.size())); + + // enable only needed + getInstance().allServices.forEach(service -> { + boolean isDefault = false; + isDefault |= enablePrintGameLogs && service.getServiceCode().equals(PrintGameLogsDataCollector.SERVICE_CODE); + isDefault |= enableSaveGameHistory && service.getServiceCode().equals(SaveGameHistoryDataCollector.SERVICE_CODE); + boolean isEnable = isServiceEnable(service.getServiceCode(), isDefault); + if (isEnable) { + getInstance().activeServices.add(service); + } + String info = isEnable ? String.format(" (%s)", service.getInitInfo()) : ""; + logger.info(String.format("Data collectors: %s - %s%s", service.getServiceCode(), isEnable ? "enabled" : "disabled", info)); + }); + } + + private static boolean isServiceEnable(String dataCollectorCode, boolean isEnableByDefault) { + String needCommand = COMMAND_LINE_DATA_COLLECTORS_PREFIX + dataCollectorCode; + boolean isEnable; + if (System.getProperty(needCommand) != null) { + isEnable = System.getProperty(needCommand, "false").equals("true"); + } else { + isEnable = isEnableByDefault; + } + return isEnable; + } + + @Override + public String getServiceCode() { + throw new IllegalStateException("Wrong code usage. Use it by static methods only"); + } + + @Override + public String getInitInfo() { + throw new IllegalStateException("Wrong code usage. Use it by static methods only"); + } + + @Override + public void onServerStart() { + activeServices.forEach(DataCollector::onServerStart); + } + + @Override + public void onTableStart(Table table) { + activeServices.forEach(c -> c.onTableStart(table)); + } + + @Override + public void onTableEnd(Table table) { + activeServices.forEach(c -> c.onTableEnd(table)); + } + + @Override + public void onGameStart(Game game) { + if (game.isSimulation()) return; + activeServices.forEach(c -> c.onGameStart(game)); + } + + @Override + public void onGameLog(Game game, String message) { + if (game.isSimulation()) return; + activeServices.forEach(c -> c.onGameLog(game, message)); + } + + @Override + public void onGameEnd(Game game) { + if (game.isSimulation()) return; + activeServices.forEach(c -> c.onGameEnd(game)); + } + + @Override + public void onChatRoom(UUID roomId, String userName, String message) { + activeServices.forEach(c -> c.onChatRoom(roomId, userName, message)); + } + + @Override + public void onChatTourney(UUID tourneyId, String userName, String message) { + activeServices.forEach(c -> c.onChatTourney(tourneyId, userName, message)); + } + + @Override + public void onChatTable(UUID tableId, String userName, String message) { + activeServices.forEach(c -> c.onChatTable(tableId, userName, message)); + } + + @Override + public void onChatGame(UUID gameId, String userName, String message) { + activeServices.forEach(c -> c.onChatGame(gameId, userName, message)); + } +} diff --git a/Mage/src/main/java/mage/collectors/services/EmptyDataCollector.java b/Mage/src/main/java/mage/collectors/services/EmptyDataCollector.java new file mode 100644 index 00000000000..2e5f1793e81 --- /dev/null +++ b/Mage/src/main/java/mage/collectors/services/EmptyDataCollector.java @@ -0,0 +1,70 @@ +package mage.collectors.services; + +import mage.collectors.DataCollector; +import mage.game.Game; +import mage.game.Table; + +import java.util.UUID; + +/** + * Base implementation of Data Collector, do nothing. Use it to implement own or simple collectors, e.g. chats only collectors + * + * @author JayDi85 + */ +public abstract class EmptyDataCollector implements DataCollector { + + @Override + public String getInitInfo() { + return ""; + } + + @Override + public void onServerStart() { + // nothing + } + + @Override + public void onTableStart(Table table) { + // nothing + } + + @Override + public void onTableEnd(Table table) { + // nothing + } + + @Override + public void onGameStart(Game game) { + // nothing + } + + @Override + public void onGameLog(Game game, String message) { + // nothing + } + + @Override + public void onGameEnd(Game game) { + // nothing + } + + @Override + public void onChatRoom(UUID roomId, String userName, String message) { + // nothing + } + + @Override + public void onChatTourney(UUID tourneyId, String userName, String message) { + // nothing + } + + @Override + public void onChatTable(UUID tableId, String userName, String message) { + // nothing + } + + @Override + public void onChatGame(UUID gameId, String userName, String message) { + // nothing + } +} diff --git a/Mage/src/main/java/mage/collectors/services/PrintGameLogsDataCollector.java b/Mage/src/main/java/mage/collectors/services/PrintGameLogsDataCollector.java new file mode 100644 index 00000000000..f8fc9a8966a --- /dev/null +++ b/Mage/src/main/java/mage/collectors/services/PrintGameLogsDataCollector.java @@ -0,0 +1,48 @@ +package mage.collectors.services; + +import mage.game.Game; +import mage.util.CardUtil; +import org.apache.log4j.Logger; +import org.jsoup.Jsoup; + +/** + * Data collector to print game logs in console output. Used for better unit tests debugging. + * + * @author JayDi85 + */ +public class PrintGameLogsDataCollector extends EmptyDataCollector { + + private static final Logger logger = Logger.getLogger(PrintGameLogsDataCollector.class); + public static final String SERVICE_CODE = "printGameLogs"; + + private void writeLog(String message) { + logger.info(message); + } + + private void writeLog(String category, String event, String details) { + writeLog(String.format("[%s][%s] %s", + category, + event, + details + )); + } + + @Override + public String getServiceCode() { + return SERVICE_CODE; + } + + @Override + public String getInitInfo() { + return "print game logs in server logs"; + } + + @Override + public void onGameLog(Game game, String message) { + String needMessage = Jsoup.parse(message).text(); + writeLog("GAME", "LOG", String.format("%s: %s", + CardUtil.getTurnInfo(game), + needMessage + )); + } +} diff --git a/Mage/src/main/java/mage/collectors/services/SaveGameHistoryDataCollector.java b/Mage/src/main/java/mage/collectors/services/SaveGameHistoryDataCollector.java new file mode 100644 index 00000000000..7d6212f0fbf --- /dev/null +++ b/Mage/src/main/java/mage/collectors/services/SaveGameHistoryDataCollector.java @@ -0,0 +1,383 @@ +package mage.collectors.services; + +import mage.cards.decks.DeckFormats; +import mage.constants.TableState; +import mage.game.Game; +import mage.game.Table; +import mage.game.match.MatchPlayer; +import mage.players.Player; +import mage.util.CardUtil; +import org.apache.log4j.Logger; +import org.jsoup.Jsoup; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; + +/** + * Data collector that can collect and save whole games history and store it in disk system. + *

+ * WARNING, it's not production ready yet, use for load tests only (see todos below) + *

+ * Possible use cases: + * - load tests debugging to find freeze AI games; + * - public server debugging to find human freeze games; + * - AI learning data collection; + * - fast access to saved decks for public tourneys, e.g. after draft + * Tasks: + * - TODO: drafts - save picks history per player; + * - TODO: tourneys - fix chat logs + * - TODO: tourneys - fix miss end events on table or server quite (active table/game freeze bug) + *

+ * Data structure example: + * - gamesHistory + * - 2025-07-04 + * - tables_active + * - tables_done + * - table 1 - UUID + * - table_logs.txt + * - games_done + * - games_active + * - game 1 - UUID + * - game 2 - UUID + * - game_logs.html + * - chat_logs.html + * - deck_player_1.dck + * - deck_player_2.dck + * + * @author JayDi85 + */ +public class SaveGameHistoryDataCollector extends EmptyDataCollector { + + private static final Logger logger = Logger.getLogger(SaveGameHistoryDataCollector.class); + public static final String SERVICE_CODE = "saveGameHistory"; + + private static final String DIR_NAME_ROOT = "gamesHistory"; + private static final SimpleDateFormat DIR_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd"); + private static final String DIR_NAME_TABLES_ACTIVE = "tables_active"; + private static final String DIR_NAME_TABLES_DONE = "tables_done"; + private static final String DIR_NAME_GAMES_ACTIVE = "games_active"; + private static final String DIR_NAME_GAMES_DONE = "games_done"; + + private static final String TABLE_LOGS_FILE_NAME = "table_logs.txt"; + private static final String TABLE_CHAT_FILE_NAME = "table_chat.txt"; + private static final String DECK_FILE_NAME_FORMAT = "deck_player_%d.dck"; + private static final String GAME_LOGS_FILE_NAME = "game_logs.html"; + private static final String GAME_CHAT_FILE_NAME = "game_chat.txt"; + + private static final UUID NO_TABLE_ID = UUID.randomUUID(); + private static final String NO_TABLE_NAME = "SINGLE"; // need for unit tests + + // global switch + boolean enabled; + + // prepared dirs for each table or game - if it returns empty string then logs will be disabled, e.g. on too many data + Map tableDirs = new ConcurrentHashMap<>(); + Map gameDirs = new ConcurrentHashMap<>(); + + // all write operations must be done in single thread + // TODO: analyse load tests performance and split locks per table/game + // TODO: limit file sizes for possible game freeze? + ReentrantLock writeLock = new ReentrantLock(); + + public SaveGameHistoryDataCollector() { + // prepare + Path root = Paths.get(DIR_NAME_ROOT); + if (!Files.exists(root)) { + try { + Files.createDirectories(root); + } catch (IOException e) { + logger.error("Can't create root dir, games history data collector will be disabled - " + e, e); + this.enabled = false; + return; + } + } + + this.enabled = true; + } + + @Override + public String getServiceCode() { + return SERVICE_CODE; + } + + @Override + public String getInitInfo() { + return "save all game history to " + Paths.get(DIR_NAME_ROOT, DIR_DATE_FORMAT.format(new Date())).toAbsolutePath(); + } + + @Override + public void onTableStart(Table table) { + if (!this.enabled) return; + writeToTableLogsFile(table, new Date() + " [START] " + table.getId() + ", " + table); + } + + @Override + public void onTableEnd(Table table) { + if (!this.enabled) return; + writeToTableLogsFile(table, new Date() + " [END] " + table.getId() + ", " + table); + + // good end - move all files to done folder and change dir refs for possible game and other logs + writeLock.lock(); + try { + String oldFolder = getOrCreateTableDir(table.getId(), table.getParentTableId(), table.getTableIndex(), false); + if (oldFolder.contains(DIR_NAME_TABLES_ACTIVE)) { + // move files + String newFolder = oldFolder.replace(DIR_NAME_TABLES_ACTIVE, DIR_NAME_TABLES_DONE); + Files.createDirectories(Paths.get(newFolder)); + Files.move(Paths.get(oldFolder), Paths.get(newFolder), StandardCopyOption.REPLACE_EXISTING); + + // update all refs (table and games) + this.tableDirs.put(table.getId(), newFolder); + this.gameDirs.replaceAll((gameId, gameFolder) -> + gameFolder.startsWith(oldFolder) + ? gameFolder.replace(oldFolder, newFolder) + : gameFolder + ); + innerDirDeleteEmptyParent(oldFolder); + } + } catch (IOException e) { + logger.error("Can't move table files to done folder: " + e, e); + } finally { + writeLock.unlock(); + } + } + + @Override + public void onGameStart(Game game) { + if (!this.enabled) return; + writeToGameLogsFile(game, new Date() + " [START] " + game.getId() + ", " + game); + + // save deck files + writeLock.lock(); + try { + String gameDir = getOrCreateGameDir(game, isActive(game)); + if (gameDir.isEmpty()) { + return; + } + int playerNum = 0; + for (Player player : game.getPlayers().values()) { + playerNum++; + MatchPlayer matchPlayer = player.getMatchPlayer(); + if (matchPlayer != null && matchPlayer.getDeck() != null) { + String deckFile = Paths.get(gameDir, String.format(DECK_FILE_NAME_FORMAT, playerNum)).toString(); + DeckFormats.XMAGE.getExporter().writeDeck(deckFile, matchPlayer.getDeckForViewer().prepareCardsOnlyDeck()); + } + } + } catch (IOException e) { + logger.error("Can't write deck file for game " + game.getId() + ": " + e, e); + } finally { + writeLock.unlock(); + } + } + + @Override + public void onGameLog(Game game, String message) { + if (!this.enabled) return; + writeToGameLogsFile(game, new Date() + " [LOG] " + CardUtil.getTurnInfo(game) + ": " + message); + } + + @Override + public void onGameEnd(Game game) { + if (!this.enabled) return; + writeToGameLogsFile(game, new Date() + " [END] " + game.getId() + ", " + game); + + // good end - move game data to done folder + writeLock.lock(); + try { + String oldFolder = getOrCreateGameDir(game, false); + if (oldFolder.contains(DIR_NAME_GAMES_ACTIVE)) { + // move files + String newFolder = oldFolder.replace(DIR_NAME_GAMES_ACTIVE, DIR_NAME_GAMES_DONE); + Files.createDirectories(Paths.get(newFolder)); + Files.move(Paths.get(oldFolder), Paths.get(newFolder), StandardCopyOption.REPLACE_EXISTING); + + // update all refs + this.gameDirs.replaceAll((gameId, gameFolder) -> + gameFolder.startsWith(oldFolder) + ? gameFolder.replace(oldFolder, newFolder) + : gameFolder + ); + innerDirDeleteEmptyParent(oldFolder); + } + } catch (IOException e) { + logger.error("Can't move game files to done folder: " + e, e); + } finally { + writeLock.unlock(); + } + } + + @Override + public void onChatTourney(UUID tourneyId, String userName, String message) { + // TODO: implement? + } + + @Override + public void onChatTable(UUID tableId, String userName, String message) { + if (!this.enabled) return; + String needMessage = Jsoup.parse(message).text(); // convert html to txt format, so users can't break something + writeToTableChatFile(tableId, new Date() + " [CHAT] " + (userName == null ? "system" : userName) + ": " + needMessage); + } + + @Override + public void onChatGame(UUID gameId, String userName, String message) { + if (!this.enabled) return; + String needMessage = Jsoup.parse(message).text(); // convert html to txt format, so users can't break something + writeToGameChatFile(gameId, new Date() + " [CHAT] " + (userName == null ? "system" : userName) + ": " + needMessage); + } + + + private String getOrCreateTableDir(UUID tableId) { + return this.tableDirs.getOrDefault(tableId, ""); + } + + private String getOrCreateTableDir(UUID tableId, UUID parentTableId, Integer tableIndex, boolean isActive) { + // unit tests don't have tables, so write it to custom table + String needName; + UUID needId; + if (tableId == null) { + needId = NO_TABLE_ID; + needName = String.format("table %s", NO_TABLE_NAME); + } else { + needId = tableId; + if (parentTableId == null) { + needName = String.format("table %s - %s", tableIndex, tableId); + } else { + needName = String.format("table %s - %s_%s", tableIndex, parentTableId, tableId); + } + } + + String needDate = DIR_DATE_FORMAT.format(new Date()); + String needStatus = isActive ? DIR_NAME_TABLES_ACTIVE : DIR_NAME_TABLES_DONE; + + String tableDir = Paths.get( + DIR_NAME_ROOT, + needDate, + needStatus, + needName + ).toString(); + + return this.tableDirs.computeIfAbsent(needId, x -> innerDirCreate(tableDir)); + } + + private String getOrCreateGameDir(UUID gameId) { + // some events don't have full game info and must come after real game start + return this.gameDirs.getOrDefault(gameId, ""); + } + + private String getOrCreateGameDir(Game game, boolean isActive) { + AtomicBoolean isNewDir = new AtomicBoolean(false); + String res = this.gameDirs.computeIfAbsent(game.getId(), x -> { + isNewDir.set(true); + // if you find "table 0 - UUID" folders with real server then game logs come before table then + // it's almost impossible and nothing to do with it + String tableDir = getOrCreateTableDir(game.getTableId(), null, 0, true); + if (tableDir.isEmpty()) { + // disabled + return ""; + } + + String needStatus = isActive ? DIR_NAME_GAMES_ACTIVE : DIR_NAME_GAMES_DONE; + String needName = String.format("game %s - %s", game.getGameIndex(), game.getId()); + String gameDir = Paths.get( + tableDir, + needStatus, + needName + ).toString(); + + return innerDirCreate(gameDir); + }); + + // workaround for good background color in html logs + if (isNewDir.get()) { + writeToGameLogsFile(game, ""); + } + + return res; + } + + private String innerDirCreate(String destFileOrDir) { + Path dir = Paths.get(destFileOrDir); + if (!Files.exists(dir)) { + try { + Files.createDirectories(dir); + } catch (IOException ignore) { + return ""; + } + } + return dir.toString(); + } + + private void innerDirDeleteEmptyParent(String dir) throws IOException { + // delete empty parent folder, e.g. after move all games from active to done folder + Path parentPath = Paths.get(dir).getParent(); + if (Files.exists(parentPath) && Files.isDirectory(parentPath)) { + try (Stream entries = Files.list(parentPath)) { + if (!entries.findAny().isPresent()) { + Files.delete(parentPath); + } + } + } + } + + private void writeToTableLogsFile(Table table, String data) { + String tableDir = getOrCreateTableDir(table.getId(), table.getParentTableId(), table.getTableIndex(), isActive(table)); + if (tableDir.isEmpty()) { + return; + } + writeToFile(Paths.get(tableDir, TABLE_LOGS_FILE_NAME).toString(), data, "\n"); + } + + private void writeToTableChatFile(UUID tableId, String data) { + String gameDir = getOrCreateTableDir(tableId); + if (gameDir.isEmpty()) { + return; + } + writeToFile(Paths.get(gameDir, TABLE_CHAT_FILE_NAME).toString(), data, "\n"); + } + + private void writeToGameLogsFile(Game game, String data) { + String gameDir = getOrCreateGameDir(game, isActive(game)); + if (gameDir.isEmpty()) { + return; + } + writeToFile(Paths.get(gameDir, GAME_LOGS_FILE_NAME).toString(), data, "\n
\n"); + } + + private void writeToGameChatFile(UUID gameId, String data) { + String gameDir = getOrCreateGameDir(gameId); + if (gameDir.isEmpty()) { + return; + } + writeToFile(Paths.get(gameDir, GAME_CHAT_FILE_NAME).toString(), data, "\n"); + } + + private void writeToFile(String destFile, String data, String newLine) { + writeLock.lock(); + try { + try { + String newData = newLine + data; + Files.write(Paths.get(destFile), newData.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException ignore) { + } + } finally { + writeLock.unlock(); + } + } + + private boolean isActive(Table table) { + return !TableState.FINISHED.equals(table.getState()); + } + + private boolean isActive(Game game) { + return game.getState() == null || !game.getState().isGameOver(); + } +} diff --git a/Mage/src/main/java/mage/game/Game.java b/Mage/src/main/java/mage/game/Game.java index 13846b795b8..ad0cd9232d4 100644 --- a/Mage/src/main/java/mage/game/Game.java +++ b/Mage/src/main/java/mage/game/Game.java @@ -48,6 +48,11 @@ import java.util.stream.Collectors; public interface Game extends MageItem, Serializable, Copyable { + /** + * Return global game index (used for better logs and history) + */ + Integer getGameIndex(); + MatchType getGameType(); int getNumPlayers(); @@ -137,6 +142,7 @@ public interface Game extends MageItem, Serializable, Copyable { Map getPermanentsEntering(); Map> getLKI(); + Map> getPermanentCostsTags(); /** @@ -171,9 +177,9 @@ public interface Game extends MageItem, Serializable, Copyable { PlayerList getPlayerList(); /** - * Returns opponents list in range for the given playerId. Use it to interate by starting turn order. - * - * Warning, it will return leaved players until end of turn. For dialogs and one shot effects use excludeLeavedPlayers + * Returns opponents list in range for the given playerId. Use it to interate by starting turn order. + *

+ * Warning, it will return leaved players until end of turn. For dialogs and one shot effects use excludeLeavedPlayers */ // TODO: check usage of getOpponents in cards and replace with correct call of excludeLeavedPlayers, see #13289 default Set getOpponents(UUID playerId) { @@ -181,8 +187,8 @@ public interface Game extends MageItem, Serializable, Copyable { } /** - * Returns opponents list in range for the given playerId. Use it to interate by starting turn order. - * Warning, it will return dead players until end of turn. + * Returns opponents list in range for the given playerId. Use it to interate by starting turn order. + * Warning, it will return dead players until end of turn. * * @param excludeLeavedPlayers exclude dead player immediately without waiting range update on next turn */ @@ -245,7 +251,7 @@ public interface Game extends MageItem, Serializable, Copyable { /** * Id of the player the current turn it is. - * + *

* Player can be under control of another player, so search a real GUI's controller by Player->getTurnControlledBy * * @return @@ -563,14 +569,15 @@ public interface Game extends MageItem, Serializable, Copyable { */ void processAction(); - @Deprecated // TODO: must research usage and remove it from all non engine code (example: Bestow ability, ProcessActions must be used instead) + @Deprecated + // TODO: must research usage and remove it from all non engine code (example: Bestow ability, ProcessActions must be used instead) boolean checkStateAndTriggered(); /** * Play priority by all players * * @param activePlayerId starting priority player - * @param resuming false to reset passed priority and ask it again + * @param resuming false to reset passed priority and ask it again */ void playPriority(UUID activePlayerId, boolean resuming); @@ -600,6 +607,7 @@ public interface Game extends MageItem, Serializable, Copyable { /** * TODO: remove logic changed, must research each usage of removeBookmark and replace it with new code + * * @param bookmark */ void removeBookmark_v2(int bookmark); @@ -620,6 +628,7 @@ public interface Game extends MageItem, Serializable, Copyable { // game cheats (for tests only) void cheat(UUID ownerId, Map commands); + void cheat(UUID ownerId, List library, List hand, List battlefield, List graveyard, List command, List exiled); // controlling the behaviour of replacement effects while permanents entering the battlefield @@ -811,4 +820,8 @@ public interface Game extends MageItem, Serializable, Copyable { boolean isGameStopped(); boolean isTurnOrderReversed(); + + UUID getTableId(); + + void setTableId(UUID tableId); } diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index dea911f2ace..e3f59becee3 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -27,6 +27,7 @@ import mage.cards.*; import mage.cards.decks.Deck; import mage.cards.decks.DeckCardInfo; import mage.choices.Choice; +import mage.collectors.DataCollectorServices; import mage.constants.*; import mage.counters.CounterType; import mage.counters.Counters; @@ -94,6 +95,8 @@ import java.util.stream.Collectors; */ public abstract class GameImpl implements Game { + private final static AtomicInteger GLOBAL_INDEX = new AtomicInteger(); + private static final int ROLLBACK_TURNS_MAX = 4; private static final String UNIT_TESTS_ERROR_TEXT = "Error in unit tests"; private static final Logger logger = Logger.getLogger(GameImpl.class); @@ -108,6 +111,8 @@ public abstract class GameImpl implements Game { protected AtomicInteger totalErrorsCount = new AtomicInteger(); // for debug only: error stats protected final UUID id; + protected final Integer gameIndex; // for better logs and history + protected UUID tableId = null; protected boolean ready; protected transient TableEventSource tableEventSource = new TableEventSource(); @@ -171,6 +176,7 @@ public abstract class GameImpl implements Game { public GameImpl(MultiplayerAttackOption attackOption, RangeOfInfluence range, Mulligan mulligan, int minimumDeckSize, int startingLife, int startingHandSize) { this.id = UUID.randomUUID(); + this.gameIndex = GLOBAL_INDEX.incrementAndGet(); this.range = range; this.mulligan = mulligan; this.attackOption = attackOption; @@ -191,6 +197,8 @@ public abstract class GameImpl implements Game { this.checkPlayableState = game.checkPlayableState; this.id = game.id; + this.gameIndex = game.gameIndex; + this.tableId = game.tableId; this.totalErrorsCount.set(game.totalErrorsCount.get()); this.ready = game.ready; @@ -250,6 +258,11 @@ public abstract class GameImpl implements Game { */ } + @Override + public Integer getGameIndex() { + return this.gameIndex; + } + @Override public boolean isSimulation() { return simulation; @@ -1047,6 +1060,7 @@ public abstract class GameImpl implements Game { @Override public void start(UUID choosingPlayerId) { startTime = new Date(); + DataCollectorServices.getInstance().onGameStart(this); if (state.getPlayers().values().iterator().hasNext()) { init(choosingPlayerId); play(startingPlayerId); @@ -1562,6 +1576,11 @@ public abstract class GameImpl implements Game { endTime = new Date(); state.endGame(); + // cancel all player dialogs/feedbacks + for (Player player : state.getPlayers().values()) { + player.abort(); + } + // inform players about face down cards state.getBattlefield().getAllPermanents() .stream() @@ -1581,10 +1600,7 @@ public abstract class GameImpl implements Game { .sorted() .forEach(this::informPlayers); - // cancel all player dialogs/feedbacks - for (Player player : state.getPlayers().values()) { - player.abort(); - } + DataCollectorServices.getInstance().onGameEnd(this); } } @@ -3173,6 +3189,8 @@ public abstract class GameImpl implements Game { @Override public void informPlayers(String message) { + DataCollectorServices.getInstance().onGameLog(this, message); + // Uncomment to print game messages // System.out.println(message.replaceAll("\\<.*?\\>", "")); if (simulation) { @@ -4248,4 +4266,14 @@ public abstract class GameImpl implements Game { .append(this.getState().isGameOver() ? "; FINISHED: " + this.getWinner() : ""); return sb.toString(); } + + @Override + public UUID getTableId() { + return this.tableId; + } + + @Override + public void setTableId(UUID tableId) { + this.tableId = tableId; + } } diff --git a/Mage/src/main/java/mage/game/Table.java b/Mage/src/main/java/mage/game/Table.java index a7c84529f82..1126d199058 100644 --- a/Mage/src/main/java/mage/game/Table.java +++ b/Mage/src/main/java/mage/game/Table.java @@ -3,7 +3,10 @@ package mage.game; import java.io.Serializable; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + import mage.cards.decks.DeckValidator; +import mage.collectors.DataCollectorServices; import mage.constants.TableState; import mage.game.draft.Draft; import mage.game.events.Listener; @@ -20,7 +23,10 @@ import mage.players.PlayerType; */ public class Table implements Serializable { + private final static AtomicInteger GLOBAL_INDEX = new AtomicInteger(); + private UUID tableId; + private Integer tableIndex; // for better logs and history private UUID roomId; private String name; private String controllerName; @@ -54,17 +60,23 @@ public class Table implements Serializable { this.tournament = tournament; this.isTournament = true; setState(TableState.WAITING); + + DataCollectorServices.getInstance().onTableStart(this); } public Table(UUID roomId, String gameType, String name, String controllerName, DeckValidator validator, List playerTypes, TableRecorder recorder, Match match, Set bannedUsernames, boolean isPlaneChase) { this(roomId, gameType, name, controllerName, validator, playerTypes, recorder, bannedUsernames, isPlaneChase); this.match = match; + this.match.setTableId(this.getId()); this.isTournament = false; setState(TableState.WAITING); + + DataCollectorServices.getInstance().onTableStart(this); } protected Table(UUID roomId, String gameType, String name, String controllerName, DeckValidator validator, List playerTypes, TableRecorder recorder, Set bannedUsernames, boolean isPlaneChase) { - tableId = UUID.randomUUID(); + this.tableId = UUID.randomUUID(); + this.tableIndex = GLOBAL_INDEX.incrementAndGet(); this.roomId = roomId; this.numSeats = playerTypes.size(); this.gameType = gameType; @@ -91,6 +103,10 @@ public class Table implements Serializable { return tableId; } + public Integer getTableIndex() { + return tableIndex; + } + public UUID getParentTableId() { return parentTableId; } @@ -132,6 +148,8 @@ public class Table implements Serializable { setState(TableState.FINISHED); // otherwise the table can be removed completely } this.validator = null; + + DataCollectorServices.getInstance().onTableEnd(this); } /** diff --git a/Mage/src/main/java/mage/game/match/Match.java b/Mage/src/main/java/mage/game/match/Match.java index 76e74e71683..7f080674871 100644 --- a/Mage/src/main/java/mage/game/match/Match.java +++ b/Mage/src/main/java/mage/game/match/Match.java @@ -10,7 +10,7 @@ import mage.game.GameException; import mage.game.GameInfo; import mage.game.events.Listener; import mage.game.events.TableEvent; -import mage.game.result.ResultProtos.MatchProto; +import mage.game.result.ResultProtos.MatchProto; // on unknown package error must run and auto-generate proto classes by "mvn install -DskipTests" import mage.players.Player; /** diff --git a/Mage/src/main/java/mage/game/match/MatchImpl.java b/Mage/src/main/java/mage/game/match/MatchImpl.java index 3fb5def5119..0e0e2b1d119 100644 --- a/Mage/src/main/java/mage/game/match/MatchImpl.java +++ b/Mage/src/main/java/mage/game/match/MatchImpl.java @@ -192,6 +192,7 @@ public abstract class MatchImpl implements Match { } protected void initGame(Game game) throws GameException { + game.setTableId(this.tableId); addGame(); // raises only the number shufflePlayers(); for (MatchPlayer matchPlayer : this.players) { @@ -291,6 +292,9 @@ public abstract class MatchImpl implements Match { @Override public void setTableId(UUID tableId) { this.tableId = tableId; + + // sync tableId with all games (needs for data collectors) + this.games.forEach(game -> game.setTableId(tableId)); } @Override diff --git a/Mage/src/main/java/mage/game/tournament/MultiplayerRound.java b/Mage/src/main/java/mage/game/tournament/MultiplayerRound.java index 2f19c2c2d97..7fdc106b767 100644 --- a/Mage/src/main/java/mage/game/tournament/MultiplayerRound.java +++ b/Mage/src/main/java/mage/game/tournament/MultiplayerRound.java @@ -42,14 +42,11 @@ public class MultiplayerRound { return this.roundNum; } - public void setMatch (Match match) { + public void setMatchAndTable(Match match, UUID tableId) { this.match = match; - } - - public void setTableId (UUID tableId) { this.tableId = tableId; } - + public boolean isRoundOver() { boolean roundIsOver = true; if (this.match != null) { diff --git a/Mage/src/main/java/mage/game/tournament/TournamentPairing.java b/Mage/src/main/java/mage/game/tournament/TournamentPairing.java index 3939ce40f0c..986887c6711 100644 --- a/Mage/src/main/java/mage/game/tournament/TournamentPairing.java +++ b/Mage/src/main/java/mage/game/tournament/TournamentPairing.java @@ -42,8 +42,9 @@ public class TournamentPairing { return match; } - public void setMatch(Match match) { + public void setMatchAndTable(Match match, UUID tableId) { this.match = match; + this.tableId = tableId; } /** @@ -88,10 +89,6 @@ public class TournamentPairing { return tableId; } - public void setTableId(UUID tableId) { - this.tableId = tableId; - } - public boolean isAlreadyPublished() { return alreadyPublished; } diff --git a/Mage/src/main/java/mage/util/DebugUtil.java b/Mage/src/main/java/mage/util/DebugUtil.java index c76f83243eb..fcad86ea034 100644 --- a/Mage/src/main/java/mage/util/DebugUtil.java +++ b/Mage/src/main/java/mage/util/DebugUtil.java @@ -17,6 +17,12 @@ public class DebugUtil { public static boolean AI_ENABLE_DEBUG_MODE = false; public static boolean AI_SHOW_TARGET_OPTIMIZATION_LOGS = false; // works with target amount + // SERVER + // data collectors - enable additional logs and data collection for better AI and human games debugging + public static boolean TESTS_DATA_COLLECTORS_ENABLE_SAVE_GAME_HISTORY = false; // WARNING, for debug only, can generate too much files + public static boolean SERVER_DATA_COLLECTORS_ENABLE_PRINT_GAME_LOGS = false; + public static boolean SERVER_DATA_COLLECTORS_ENABLE_SAVE_GAME_HISTORY = false; + // GAME // print detail target info for activate/cast/trigger only, not a single choose dialog // can be useful to debug unit tests, auto-choose or AI