forked from External/mage
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:
parent
9812e133e1
commit
52180d1393
30 changed files with 1007 additions and 80 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ syntax: glob
|
|||
*.log.*
|
||||
*/gamelogs
|
||||
*/gamelogsJson
|
||||
*/gamesHistory
|
||||
|
||||
# Mage.Client
|
||||
Mage.Client/plugins/images
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@
|
|||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
Mage/pom.xml
46
Mage/pom.xml
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- add generated proto buffer classes into the package -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
|
|
@ -89,12 +93,10 @@
|
|||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
<finalName>mage</finalName>
|
||||
|
||||
|
||||
</build>
|
||||
|
||||
<!-- The properties below are added by following http://vlkan.com/blog/post/2015/11/27/maven-protobuf/ -->
|
||||
|
|
|
|||
68
Mage/src/main/java/mage/collectors/DataCollector.java
Normal file
68
Mage/src/main/java/mage/collectors/DataCollector.java
Normal 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);
|
||||
}
|
||||
143
Mage/src/main/java/mage/collectors/DataCollectorServices.java
Normal file
143
Mage/src/main/java/mage/collectors/DataCollectorServices.java
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue