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