Added data collectors, AI and testing tools improves:

- dev: added data collectors API to collect and process game data in real time;
- tests: added game logs output in all unit tests (enabled by default);
- tests: added games history storage (decks, game logs, chats - disabled by default);
This commit is contained in:
Oleg Agafonov 2025-07-05 20:54:33 +04:00
parent 9812e133e1
commit 52180d1393
30 changed files with 1007 additions and 80 deletions

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ syntax: glob
*.log.*
*/gamelogs
*/gamelogsJson
*/gamesHistory
# Mage.Client
Mage.Client/plugins/images

View file

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

View file

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

View file

@ -66,7 +66,6 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

View file

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

View file

@ -192,11 +192,6 @@
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>

View file

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

View file

@ -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<UUID, String> users = new ConcurrentHashMap<>(); // active users
private final Set<UUID> 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<UUID> 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<UUID> chatUserIds = new ArrayList<>();
final Lock r = lock.readLock();
r.lock();

View file

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

View file

@ -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);
}
/**

View file

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

View file

@ -85,11 +85,11 @@ public class GameController implements GameCallback {
public GameController(ManagerFactory managerFactory, Game game, ConcurrentMap<UUID, UUID> 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());

View file

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

View file

@ -54,7 +54,7 @@ public class TournamentController {
public TournamentController(ManagerFactory managerFactory, Tournament tournament, ConcurrentMap<UUID, UUID> 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);
}

View file

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

View file

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

View file

@ -22,6 +22,10 @@
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
@ -42,34 +46,34 @@
</configuration>
</plugin>
<plugin>
<groupId>com.github.os72</groupId>
<artifactId>protoc-jar-maven-plugin</artifactId>
<version>3.11.4</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>run</goal>
</goals>
<phase>generate-sources</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.25.8</protocArtifact>
<inputDirectories>
<include>${project.basedir}/src/main/proto</include>
</inputDirectories>
<outputTargets>
<outputTarget>
<type>java</type>
<addSources>none</addSources>
<outputDirectory>${project.build.directory}/generated-sources</outputDirectory>
</outputTarget>
</outputTargets>
</configuration>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.25.8</protocArtifact>
<inputDirectories>
<include>${project.basedir}/src/main/proto</include>
</inputDirectories>
<outputTargets>
<outputTarget>
<type>java</type>
<addSources>none</addSources>
<outputDirectory>${project.build.directory}/generated-sources</outputDirectory>
</outputTarget>
</outputTargets>
</configuration>
</execution>
</executions>
</plugin>
<!-- add generated proto buffer classes into the package -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
@ -93,8 +97,6 @@
</plugins>
<finalName>mage</finalName>
</build>
<!-- The properties below are added by following http://vlkan.com/blog/post/2015/11/27/maven-protobuf/ -->

View file

@ -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.
* <p>
* 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)
* <p>
* How-to enable or disable:
* - use java params like -Dxmage.dataCollectors.saveGameHistory=true
* <p>
* 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);
}

View file

@ -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<DataCollector> allServices = new LinkedHashSet<>();
Set<DataCollector> 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));
}
}

View file

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

View file

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

View file

@ -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.
* <p>
* WARNING, it's not production ready yet, use for load tests only (see todos below)
* <p>
* 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)
* <p>
* 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<UUID, String> tableDirs = new ConcurrentHashMap<>();
Map<UUID, String> 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, "<body style=\"background: #862c10\">");
}
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<Path> 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<br>\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();
}
}

View file

@ -48,6 +48,11 @@ import java.util.stream.Collectors;
public interface Game extends MageItem, Serializable, Copyable<Game> {
/**
* 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<Game> {
Map<UUID, Permanent> getPermanentsEntering();
Map<Zone, Map<UUID, MageObject>> getLKI();
Map<MageObjectReference, Map<String, Object>> getPermanentCostsTags();
/**
@ -171,9 +177,9 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
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.
* <p>
* 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<UUID> getOpponents(UUID playerId) {
@ -181,8 +187,8 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
}
/**
* 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<Game> {
/**
* Id of the player the current turn it is.
*
* <p>
* 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<Game> {
*/
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<Game> {
/**
* 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> {
// game cheats (for tests only)
void cheat(UUID ownerId, Map<Zone, String> commands);
void cheat(UUID ownerId, List<Card> library, List<Card> hand, List<PutToBattlefieldInfo> battlefield, List<Card> graveyard, List<Card> command, List<Card> exiled);
// controlling the behaviour of replacement effects while permanents entering the battlefield
@ -811,4 +820,8 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
boolean isGameStopped();
boolean isTurnOrderReversed();
UUID getTableId();
void setTableId(UUID tableId);
}

View file

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

View file

@ -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<PlayerType> playerTypes, TableRecorder recorder, Match match, Set<String> 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<PlayerType> playerTypes, TableRecorder recorder, Set<String> 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);
}
/**

View file

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

View file

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

View file

@ -42,11 +42,8 @@ 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;
}

View file

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

View file

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