diff --git a/Mage.Client/src/main/java/mage/client/MageFrame.java b/Mage.Client/src/main/java/mage/client/MageFrame.java index 1103ab95e0b..dbc2719f41d 100644 --- a/Mage.Client/src/main/java/mage/client/MageFrame.java +++ b/Mage.Client/src/main/java/mage/client/MageFrame.java @@ -124,6 +124,9 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { private static int startPort = -1; private static boolean debugMode = false; + private JToggleButton switchPanelsButton = null; // from main menu + private static String SWITCH_PANELS_BUTTON_NAME = "Switch panels"; + private static final Map CHATS = new HashMap<>(); private static final Map GAMES = new HashMap<>(); private static final Map DRAFTS = new HashMap<>(); @@ -189,9 +192,6 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } } - /** - * Creates new form MageFrame - */ public MageFrame() throws MageException { File cacertsFile = new File(System.getProperty("user.dir") + "/release/cacerts").getAbsoluteFile(); if (!cacertsFile.exists()) { // When running from the jar file the contents of the /release folder will have been expanded into the home folder as part of packaging @@ -283,6 +283,23 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { initComponents(); + // auto-update switch panels button with actual stats + desktopPane.addContainerListener(new ContainerAdapter() { + @Override + public void componentAdded(ContainerEvent e) { + if (desktopPane.getLayer(e.getComponent()) == JLayeredPane.DEFAULT_LAYER) { + updateSwitchPanelsButton(); + } + } + + @Override + public void componentRemoved(ContainerEvent e) { + if (desktopPane.getLayer(e.getComponent()) == JLayeredPane.DEFAULT_LAYER) { + updateSwitchPanelsButton(); + } + } + }); + desktopPane.setDesktopManager(new MageDesktopManager()); setSize(1024, 768); @@ -303,8 +320,12 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { updateMemUsageTask = new UpdateMemUsageTask(jMemUsageLabel); + // create default server lobby and hide it until connect tablesPane = new TablesPane(); desktopPane.add(tablesPane, javax.swing.JLayeredPane.DEFAULT_LAYER); + SwingUtilities.invokeLater(() -> { + this.hideServerLobby(); + }); addTooltipContainer(); setBackground(); @@ -565,15 +586,27 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } private AbstractButton createSwitchPanelsButton() { - final JToggleButton switchPanelsButton = new JToggleButton("Switch panels"); - switchPanelsButton.addItemListener(e -> { + this.switchPanelsButton = new JToggleButton(SWITCH_PANELS_BUTTON_NAME); + this.switchPanelsButton.addItemListener(e -> { if (e.getStateChange() == ItemEvent.SELECTED) { - createAndShowSwitchPanelsMenu((JComponent) e.getSource(), switchPanelsButton); + createAndShowSwitchPanelsMenu((JComponent) e.getSource(), this.switchPanelsButton); } }); - switchPanelsButton.setFocusable(false); - switchPanelsButton.setHorizontalTextPosition(SwingConstants.LEADING); - return switchPanelsButton; + this.switchPanelsButton.setFocusable(false); + this.switchPanelsButton.setHorizontalTextPosition(SwingConstants.LEADING); + return this.switchPanelsButton; + } + + private void updateSwitchPanelsButton() { + if (this.switchPanelsButton != null) { + int totalCount = getPanelsCount(false); + int activeCount = getPanelsCount(true); + this.switchPanelsButton.setText(SWITCH_PANELS_BUTTON_NAME + String.format(" (%d)", totalCount)); + this.switchPanelsButton.setToolTipText(String.format("Click to switch between panels (active panels: %d of %d)", + activeCount, + totalCount + )); + } } private void createAndShowSwitchPanelsMenu(final JComponent component, final AbstractButton windowButton) { @@ -762,17 +795,27 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } public void showTournament(UUID tournamentId) { + // existing tourney + TournamentPane tournamentPane = null; for (Component component : desktopPane.getComponents()) { if (component instanceof TournamentPane && ((TournamentPane) component).getTournamentId().equals(tournamentId)) { - setActive((TournamentPane) component); - return; + tournamentPane = (TournamentPane) component; } } - TournamentPane tournamentPane = new TournamentPane(); - desktopPane.add(tournamentPane, JLayeredPane.DEFAULT_LAYER); - tournamentPane.setVisible(true); - tournamentPane.showTournament(tournamentId); + + // new tourney + if (tournamentPane == null) { + tournamentPane = new TournamentPane(); + desktopPane.add(tournamentPane, JLayeredPane.DEFAULT_LAYER); + tournamentPane.setVisible(true); + tournamentPane.showTournament(tournamentId); + } + + // if user connects on startup then there are possible multiple tables open, so keep only actual + // priority: game > constructing > draft > tourney + // TODO: activate panel by priority + setActive(tournamentPane); } @@ -843,7 +886,7 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { LOGGER.debug("connecting (auto): " + currentConnection.getProxyType().toString() + ' ' + currentConnection.getProxyHost() + ' ' + currentConnection.getProxyPort() + ' ' + currentConnection.getProxyUsername()); if (MageFrame.connect(currentConnection)) { - prepareAndShowTablesPane(); + prepareAndShowServerLobby(); return true; } else { showMessage("Unable connect to server: " + SessionHandler.getLastConnectError()); @@ -1075,10 +1118,7 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { private void btnConnectActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnConnectActionPerformed if (SessionHandler.isConnected()) { - UserRequestMessage message = new UserRequestMessage("Confirm disconnect", "Are you sure you want to disconnect?"); - message.setButton1("No", null); - message.setButton2("Yes", PlayerAction.CLIENT_DISCONNECT); - showUserRequestDialog(message); + tryDisconnectOrExit(false); } else { connectDialog.showDialog(); setWindowTitle(); @@ -1148,15 +1188,34 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } public void exitApp() { + tryDisconnectOrExit(true); + } + + private void tryDisconnectOrExit(Boolean needExit) { + String actionName = needExit ? "exit" : "disconnect"; + PlayerAction actionFull = needExit ? PlayerAction.CLIENT_EXIT_FULL : PlayerAction.CLIENT_DISCONNECT_FULL; + PlayerAction actionKeepTables = needExit ? PlayerAction.CLIENT_EXIT_KEEP_GAMES : PlayerAction.CLIENT_DISCONNECT_KEEP_GAMES; + double windowSizeRatio = 1.3; if (SessionHandler.isConnected()) { - UserRequestMessage message = new UserRequestMessage("Confirm disconnect", "You are currently connected. Are you sure you want to disconnect?"); - message.setButton1("No", null); - message.setButton2("Yes", PlayerAction.CLIENT_EXIT); + int activeTables = MageFrame.getInstance().getPanelsCount(true); + UserRequestMessage message = new UserRequestMessage( + "Confirm " + actionName, + "You are connected and has " + activeTables + " active table(s). You can quit from all your tables (concede) or ask server to wait a few minutes for reconnect. What to do?" + ); + String totalInfo = (activeTables == 0 ? "" : String.format(" from %d table%s", activeTables, (activeTables > 1 ? "s" : ""))); + message.setButton1("Cancel", null); + message.setButton2("Wait for me", actionKeepTables); + message.setButton3("Quit" + totalInfo, actionFull); + message.setWindowSizeRatio(windowSizeRatio); MageFrame.getInstance().showUserRequestDialog(message); } else { - UserRequestMessage message = new UserRequestMessage("Confirm exit", "Are you sure you want to exit?"); - message.setButton1("No", null); - message.setButton2("Yes", PlayerAction.CLIENT_EXIT); + UserRequestMessage message = new UserRequestMessage( + "Confirm " + actionName, + "Are you sure you want to " + actionName + "?" + ); + message.setButton1("Cancel", null); + message.setButton2("Yes", actionFull); + message.setWindowSizeRatio(windowSizeRatio); MageFrame.getInstance().showUserRequestDialog(message); } } @@ -1171,17 +1230,18 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { btnDeckEditor.setEnabled(true); } - public void hideTables() { + public void hideServerLobby() { this.tablesPane.hideTables(); + updateSwitchPanelsButton(); } - public void setTableFilter() { + public void setServerLobbyTablesFilter() { if (this.tablesPane != null) { this.tablesPane.setTableFilter(); } } - public void prepareAndShowTablesPane() { + public void prepareAndShowServerLobby() { // Update the tables pane with the new session this.tablesPane.showTables(); @@ -1191,6 +1251,8 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { if (topPanebefore != null && topPanebefore != tablesPane) { setActive(topPanebefore); } + + updateSwitchPanelsButton(); } public void hideGames() { @@ -1253,14 +1315,18 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } public void showUserRequestDialog(final UserRequestMessage userRequestMessage) { - final UserRequestDialog userRequestDialog = new UserRequestDialog(); + if (SwingUtilities.isEventDispatchThread()) { + innerShowUserRequestDialog(userRequestMessage); + } else { + SwingUtilities.invokeLater(() -> innerShowUserRequestDialog(userRequestMessage)); + } + } + + private void innerShowUserRequestDialog(final UserRequestMessage userRequestMessage) { + UserRequestDialog userRequestDialog = new UserRequestDialog(); userRequestDialog.setLocation(100, 100); desktopPane.add(userRequestDialog, JLayeredPane.MODAL_LAYER); - if (SwingUtilities.isEventDispatchThread()) { - userRequestDialog.showDialog(userRequestMessage); - } else { - SwingUtilities.invokeLater(() -> userRequestDialog.showDialog(userRequestMessage)); - } + userRequestDialog.showDialog(userRequestMessage); } public void showErrorDialog(final String title, final String message) { @@ -1478,50 +1544,55 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { DRAFTS.put(draftId, draftPanel); } - public BalloonTip getBalloonTip() { - return balloonTip; + /** + * Return total number of panels/frames (game panel, deck editor panel, etc) + * + * @param onlyActive return only active panels (related to online like game panel, but not game viewer) + * @return + */ + public int getPanelsCount(boolean onlyActive) { + return (int) Arrays.stream(this.desktopPane.getComponentsInLayer(javax.swing.JLayeredPane.DEFAULT_LAYER)) + .filter(Component::isVisible) + .filter(p -> p instanceof MagePane) + .map(p -> (MagePane) p) + .filter(p-> !onlyActive || p.isActiveTable()) + .count(); } @Override public void connected(final String message) { - if (SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(() -> { setConnectButtonText(message); enableButtons(); - } else { - SwingUtilities.invokeLater(() -> { - setConnectButtonText(message); - enableButtons(); - }); - } + }); } @Override public void disconnected(final boolean askToReconnect) { - if (SwingUtilities.isEventDispatchThread()) { // Returns true if the current thread is an AWT event dispatching thread. + if (SwingUtilities.isEventDispatchThread()) { // REMOTE task, e.g. connecting - LOGGER.info("Disconnected from remote task"); + LOGGER.info("Disconnected from server side"); + } else { + // USER mode, e.g. user plays and got disconnect + LOGGER.info("Disconnected from client side"); + } + + SwingUtilities.invokeLater(() -> { + // user already disconnected, can't do any online actions like quite chat + // but try to keep session + // TODO: why it ignore askToReconnect here, but use custom reconnect dialog later?! Need research + SessionHandler.disconnect(false, true); setConnectButtonText(NOT_CONNECTED_BUTTON); disableButtons(); hideGames(); - hideTables(); - } else { - // USER mode, e.g. user plays and got disconnect - LOGGER.info("Disconnected from user mode"); - SwingUtilities.invokeLater(() -> { - SessionHandler.disconnect(false); // user already disconnected, can't do any online actions like quite chat - setConnectButtonText(NOT_CONNECTED_BUTTON); - disableButtons(); - hideGames(); - hideTables(); - if (askToReconnect) { - UserRequestMessage message = new UserRequestMessage("Connection lost", "The connection to server was lost. Reconnect to " + MagePreferences.getLastServerAddress() + "?"); - message.setButton1("No", null); - message.setButton2("Yes", PlayerAction.CLIENT_RECONNECT); - showUserRequestDialog(message); - } - } - ); - } + hideServerLobby(); + if (askToReconnect) { + UserRequestMessage message = new UserRequestMessage("Connection lost", "The connection to server was lost. Reconnect to " + MagePreferences.getLastServerAddress() + "?"); + message.setButton1("No", null); + message.setButton2("Yes", PlayerAction.CLIENT_RECONNECT); + showUserRequestDialog(message); + } + }); } @Override @@ -1539,8 +1610,13 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } @Override - public void processCallback(ClientCallback callback) { - callbackClient.processCallback(callback); + public void onCallback(ClientCallback callback) { + callbackClient.onCallback(callback); + } + + @Override + public void onNewConnection() { + callbackClient.onNewConnection(); } public void sendUserReplay(PlayerAction playerAction, UserRequestMessage userRequestMessage) { @@ -1551,13 +1627,11 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { case CLIENT_DOWNLOAD_CARD_IMAGES: DownloadPicturesService.startDownload(); break; - case CLIENT_DISCONNECT: - if (SessionHandler.isConnected()) { - SessionHandler.disconnect(false); - } - tablesPane.clearChat(); - showMessage("You have disconnected"); - setWindowTitle(); + case CLIENT_DISCONNECT_FULL: + doClientDisconnect(false, "You have disconnected"); + break; + case CLIENT_DISCONNECT_KEEP_GAMES: + doClientDisconnect(true, "You have disconnected and have few minutes to reconnect"); break; case CLIENT_QUIT_TOURNAMENT: SessionHandler.quitTournament(userRequestMessage.getTournamentId()); @@ -1580,15 +1654,13 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } removeGame(userRequestMessage.getGameId()); break; - case CLIENT_EXIT: - if (SessionHandler.isConnected()) { - SessionHandler.disconnect(false); - } - CardRepository.instance.closeDB(); - tablesPane.cleanUp(); - Plugins.instance.shutdown(); - dispose(); - System.exit(0); + case CLIENT_EXIT_FULL: + doClientDisconnect(false, ""); + doClientShutdownAndExit(); + break; + case CLIENT_EXIT_KEEP_GAMES: + doClientDisconnect(true, ""); + doClientShutdownAndExit(); break; case CLIENT_REMOVE_TABLE: SessionHandler.removeTable(userRequestMessage.getRoomId(), userRequestMessage.getTableId()); @@ -1609,6 +1681,26 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } } + private void doClientDisconnect(boolean keepMySessionActive, String afterMessage) { + if (SessionHandler.isConnected()) { + SessionHandler.disconnect(false, keepMySessionActive); + } + tablesPane.clearChat(); + setWindowTitle(); + + if (!afterMessage.isEmpty()) { + showMessage(afterMessage); + } + } + + private void doClientShutdownAndExit() { + tablesPane.cleanUp(); + CardRepository.instance.closeDB(); + Plugins.instance.shutdown(); + dispose(); + System.exit(0); + } + private void endTables() { for (UUID gameId : GAMES.keySet()) { SessionHandler.quitMatch(gameId); diff --git a/Mage.Client/src/main/java/mage/client/MagePane.java b/Mage.Client/src/main/java/mage/client/MagePane.java index aa5a6f48c76..19a4809b6f2 100644 --- a/Mage.Client/src/main/java/mage/client/MagePane.java +++ b/Mage.Client/src/main/java/mage/client/MagePane.java @@ -3,21 +3,19 @@ import java.awt.*; /** + * GUI: basic class for all full screen frames/tabs (example: game pane, deck editor pane, card viewer, etc) + * * @author BetaSteward_at_googlemail.com */ public abstract class MagePane extends javax.swing.JLayeredPane { private String title = "no title set"; - /** - * Creates new form MagePane - */ public MagePane() { initComponents(); } public void changeGUISize() { - } public void setTitle(String title) { @@ -53,6 +51,12 @@ return this; } + /** + * Active table: game pane, deck editor in sideboarding mode, etc + * Non active table: client side panes like card viewer, deck viewer, etc + */ + abstract public boolean isActiveTable(); + /** * This method is called from within the constructor to initialize the form. * WARNING: Do NOT modify this code. The content of this method is always diff --git a/Mage.Client/src/main/java/mage/client/SessionHandler.java b/Mage.Client/src/main/java/mage/client/SessionHandler.java index 51cadb7aca3..763b8133aad 100644 --- a/Mage.Client/src/main/java/mage/client/SessionHandler.java +++ b/Mage.Client/src/main/java/mage/client/SessionHandler.java @@ -5,6 +5,7 @@ import static mage.cards.decks.DeckFormats.XMAGE; import mage.client.chat.LocalCommands; import mage.client.constants.Constants.DeckEditorMode; import mage.client.dialog.PreferencesDialog; +import mage.client.preference.MagePreferences; import mage.constants.ManaType; import mage.constants.PlayerAction; import mage.game.match.MatchOptions; @@ -41,7 +42,6 @@ public final class SessionHandler { } public static void startSession(MageFrame mageFrame) { - session = new SessionImpl(mageFrame); session.setJsonLogActive("true".equals(PreferencesDialog.getCachedValue(PreferencesDialog.KEY_JSON_GAME_LOG_AUTO_SAVE, "true"))); } @@ -64,10 +64,22 @@ public final class SessionHandler { public static boolean connect(Connection connection) { lastConnectError = ""; + + // restore last used session + String restoreSessionId = MagePreferences.findRestoreSession(connection.getHost(), connection.getUsername()); + if (!restoreSessionId.isEmpty()) { + logger.info("Connect: trying to restore old session for user " + connection.getUsername()); + session.setRestoreSessionId(restoreSessionId); + } + + // connect if (session.connectStart(connection)) { + // save current session for restore + MagePreferences.saveRestoreSession(connection.getHost(), connection.getUsername(), getSessionId()); return true; } else { lastConnectError = session.getLastError(); + disconnect(false, true); return false; } } @@ -80,8 +92,16 @@ public final class SessionHandler { return session.connectAbort(); } - public static void disconnect(boolean showmessage) { - session.connectStop(showmessage); + public static void disconnect(boolean askForReconnect, boolean keepMySessionActive) { + if (!keepMySessionActive) { + String serverName = session.getServerHost(); + String userName = session.getUserName(); + if (!serverName.isEmpty() && !userName.isEmpty()) { + MagePreferences.saveRestoreSession(serverName, userName, ""); + } + } + + session.connectStop(askForReconnect, keepMySessionActive); } public static void sendPlayerAction(PlayerAction playerAction, UUID gameId, Object relatedUserId) { diff --git a/Mage.Client/src/main/java/mage/client/cards/VirtualCardInfo.java b/Mage.Client/src/main/java/mage/client/cards/VirtualCardInfo.java index 73a0715370b..a537320007d 100644 --- a/Mage.Client/src/main/java/mage/client/cards/VirtualCardInfo.java +++ b/Mage.Client/src/main/java/mage/client/cards/VirtualCardInfo.java @@ -9,10 +9,9 @@ import mage.client.dialog.PreferencesDialog; import mage.client.plugins.adapters.MageActionCallback; import mage.client.plugins.impl.Plugins; import mage.client.util.ClientDefaultSettings; -import mage.utils.SystemUtil; +import mage.util.ThreadUtils; import mage.view.CardView; -import javax.swing.*; import java.awt.*; import java.util.UUID; @@ -107,7 +106,7 @@ public class VirtualCardInfo { } public boolean prepared() { - SystemUtil.ensureRunInGUISwingThread(); + ThreadUtils.ensureRunInGUISwingThread(); return this.cardView != null && this.cardComponent != null diff --git a/Mage.Client/src/main/java/mage/client/chat/LocalCommands.java b/Mage.Client/src/main/java/mage/client/chat/LocalCommands.java index 2b71177f104..d88089af264 100644 --- a/Mage.Client/src/main/java/mage/client/chat/LocalCommands.java +++ b/Mage.Client/src/main/java/mage/client/chat/LocalCommands.java @@ -37,7 +37,7 @@ public final class LocalCommands { return false; } - final String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + final String serverAddress = SessionHandler.getSession().getServerHost(); Optional response = Optional.empty(); String command = st.nextToken(); @@ -69,6 +69,6 @@ public final class LocalCommands { final String text = new StringBuilder().append("").append(response).append("").toString(); ClientCallback chatMessage = new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, new ChatMessage("", text, new Date(), null, ChatMessage.MessageColor.BLUE)); - MageFrame.getInstance().processCallback(chatMessage); + MageFrame.getInstance().onCallback(chatMessage); } } diff --git a/Mage.Client/src/main/java/mage/client/components/MageEditorPane.java b/Mage.Client/src/main/java/mage/client/components/MageEditorPane.java index 10b3bd1e718..58a027a1962 100644 --- a/Mage.Client/src/main/java/mage/client/components/MageEditorPane.java +++ b/Mage.Client/src/main/java/mage/client/components/MageEditorPane.java @@ -11,7 +11,6 @@ import mage.client.game.GamePanel; import mage.game.command.Plane; import mage.util.CardUtil; import mage.util.GameLog; -import mage.utils.ThreadUtils; import mage.view.CardView; import mage.view.PlaneView; diff --git a/Mage.Client/src/main/java/mage/client/components/MageUI.java b/Mage.Client/src/main/java/mage/client/components/MageUI.java index 6e263f0751e..555e8a4faaf 100644 --- a/Mage.Client/src/main/java/mage/client/components/MageUI.java +++ b/Mage.Client/src/main/java/mage/client/components/MageUI.java @@ -1,6 +1,6 @@ package mage.client.components; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; import org.apache.log4j.Logger; import java.awt.Component; diff --git a/Mage.Client/src/main/java/mage/client/deckeditor/DeckEditorPane.java b/Mage.Client/src/main/java/mage/client/deckeditor/DeckEditorPane.java index 5d769595dd0..d2b7df1fc79 100644 --- a/Mage.Client/src/main/java/mage/client/deckeditor/DeckEditorPane.java +++ b/Mage.Client/src/main/java/mage/client/deckeditor/DeckEditorPane.java @@ -12,13 +12,14 @@ import java.util.Map; import java.util.UUID; /** + * GUI: deck editor, used all around the app + * * @author BetaSteward_at_googlemail.com */ public class DeckEditorPane extends MagePane { - /** - * Creates new form TablesPane - */ + private UUID tableId = null; + public DeckEditorPane() { boolean initialized = false; if (Plugins.instance.isThemePluginLoaded()) { @@ -45,6 +46,7 @@ public class DeckEditorPane extends MagePane { } public void show(DeckEditorMode mode, Deck deck, String name, UUID tableId, int time) { + this.tableId = tableId; if (mode == DeckEditorMode.SIDEBOARDING || mode == DeckEditorMode.LIMITED_BUILDING || mode == DeckEditorMode.LIMITED_SIDEBOARD_BUILDING) { @@ -60,6 +62,11 @@ public class DeckEditorPane extends MagePane { this.repaint(); } + @Override + public boolean isActiveTable() { + return this.tableId != null; + } + public DeckEditorMode getDeckEditorMode() { return this.deckEditorPanel1.getDeckEditorMode(); } diff --git a/Mage.Client/src/main/java/mage/client/deckeditor/collection/viewer/CollectionViewerPane.java b/Mage.Client/src/main/java/mage/client/deckeditor/collection/viewer/CollectionViewerPane.java index fd9a28734fb..fea26a5d0b8 100644 --- a/Mage.Client/src/main/java/mage/client/deckeditor/collection/viewer/CollectionViewerPane.java +++ b/Mage.Client/src/main/java/mage/client/deckeditor/collection/viewer/CollectionViewerPane.java @@ -52,6 +52,11 @@ public class CollectionViewerPane extends MagePane { ); } + @Override + public boolean isActiveTable() { + return false; + } + @Override public void setVisible(boolean aFlag) { super.setVisible(aFlag); diff --git a/Mage.Client/src/main/java/mage/client/dialog/ConnectDialog.java b/Mage.Client/src/main/java/mage/client/dialog/ConnectDialog.java index 9fc87f1bcce..c88fc4981f0 100644 --- a/Mage.Client/src/main/java/mage/client/dialog/ConnectDialog.java +++ b/Mage.Client/src/main/java/mage/client/dialog/ConnectDialog.java @@ -1,6 +1,5 @@ package mage.client.dialog; -import mage.cards.repository.RepositoryUtil; import mage.choices.Choice; import mage.choices.ChoiceImpl; import mage.client.MageFrame; @@ -8,10 +7,8 @@ import mage.client.SessionHandler; import mage.client.preference.MagePreferences; import mage.client.util.ClientDefaultSettings; import mage.client.util.gui.countryBox.CountryItemEditor; -import mage.client.util.sets.ConstructedFormats; import mage.remote.Connection; import mage.utils.StreamUtils; -import mage.utils.ThreadUtils; import org.apache.log4j.Logger; import javax.swing.*; @@ -702,7 +699,7 @@ public class ConnectDialog extends MageDialog { // so the connection dialog will be visible all that time SwingUtilities.invokeLater(() -> { doAfterConnected(); - MageFrame.getInstance().prepareAndShowTablesPane(); + MageFrame.getInstance().prepareAndShowServerLobby(); btnConnect.setEnabled(true); }); } else { diff --git a/Mage.Client/src/main/java/mage/client/dialog/NewTableDialog.java b/Mage.Client/src/main/java/mage/client/dialog/NewTableDialog.java index b692501ed12..965682cd44a 100644 --- a/Mage.Client/src/main/java/mage/client/dialog/NewTableDialog.java +++ b/Mage.Client/src/main/java/mage/client/dialog/NewTableDialog.java @@ -602,7 +602,7 @@ public class NewTableDialog extends MageDialog { options.setQuitRatio((Integer) this.spnQuitRatio.getValue()); options.setMinimumRating((Integer) this.spnMinimumRating.getValue()); options.setEdhPowerLevel((Integer) this.spnEdhPowerLevel.getValue()); - String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + String serverAddress = SessionHandler.getSession().getServerHost(); options.setBannedUsers(IgnoreList.getIgnoredUsers(serverAddress)); options.setLimited(options.getDeckType().startsWith("Limited")); if (options.getDeckType().startsWith("Variant Magic - Freeform Unlimited Commander")) { diff --git a/Mage.Client/src/main/java/mage/client/dialog/NewTournamentDialog.java b/Mage.Client/src/main/java/mage/client/dialog/NewTournamentDialog.java index ef2cc81ca4a..7ccc597b5e8 100644 --- a/Mage.Client/src/main/java/mage/client/dialog/NewTournamentDialog.java +++ b/Mage.Client/src/main/java/mage/client/dialog/NewTournamentDialog.java @@ -1322,7 +1322,7 @@ public class NewTournamentDialog extends MageDialog { } } - String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + String serverAddress = SessionHandler.getSession().getServerHost(); tOptions.getMatchOptions().setBannedUsers(IgnoreList.getIgnoredUsers(serverAddress)); tOptions.getMatchOptions().setMatchTimeLimit((MatchTimeLimit) this.cbTimeLimit.getSelectedItem()); diff --git a/Mage.Client/src/main/java/mage/client/dialog/UserRequestDialog.java b/Mage.Client/src/main/java/mage/client/dialog/UserRequestDialog.java index dfb7acc1a6d..080ec300e48 100644 --- a/Mage.Client/src/main/java/mage/client/dialog/UserRequestDialog.java +++ b/Mage.Client/src/main/java/mage/client/dialog/UserRequestDialog.java @@ -10,7 +10,8 @@ import javax.swing.plaf.basic.BasicInternalFrameUI; import java.awt.*; /** - * App GUI: confirm some actions from the user (example: close the app) + * GUI: global window message with additional action to choose (example: close the app) + * Can be used in any places (in games, in app, etc) * * @author BetaSteward_at_googlemail.com */ @@ -74,6 +75,17 @@ public class UserRequestDialog extends MageDialog { } else { this.btn3.setVisible(false); } + + // improved auto-size + // height looks bad, so change only width + this.pack(); + Dimension newPreferedSize = new Dimension(this.getPreferredSize()); + newPreferedSize.setSize( + newPreferedSize.width * userRequestMessage.getWindowSizeRatio(), + newPreferedSize.height/* * userRequestMessage.getWindowSizeRatio()*/ + ); + this.setPreferredSize(newPreferedSize); + this.pack(); this.revalidate(); this.repaint(); @@ -177,7 +189,7 @@ public class UserRequestDialog extends MageDialog { }//GEN-LAST:event_btn3ActionPerformed private void sendUserReplay(PlayerAction playerAction) { - MageFrame.getInstance().sendUserReplay(playerAction, userRequestMessage); + SwingUtilities.invokeLater(() ->MageFrame.getInstance().sendUserReplay(playerAction, userRequestMessage)); } // Variables declaration - do not modify//GEN-BEGIN:variables diff --git a/Mage.Client/src/main/java/mage/client/draft/DraftPane.java b/Mage.Client/src/main/java/mage/client/draft/DraftPane.java index ad9a77b28de..dd30573a57d 100644 --- a/Mage.Client/src/main/java/mage/client/draft/DraftPane.java +++ b/Mage.Client/src/main/java/mage/client/draft/DraftPane.java @@ -9,15 +9,14 @@ import mage.client.MagePane; import mage.client.plugins.impl.Plugins; /** - * Game GUI: draft panel with scrolls + * Game GUI: draft frame * * @author BetaSteward_at_googlemail.com */ public class DraftPane extends MagePane { - /** - * Creates new form DraftPane - */ + UUID draftId = null; + public DraftPane() { boolean initialized = false; if (Plugins.instance.isThemePluginLoaded()) { @@ -44,10 +43,16 @@ public class DraftPane extends MagePane { } public void showDraft(UUID draftId) { + this.draftId = draftId; this.setTitle("Draft - " + draftId); this.draftPanel1.showDraft(draftId); } + @Override + public boolean isActiveTable() { + return this.draftId != null; + } + public void removeDraft() { draftPanel1.cleanUp(); this.removeFrame(); diff --git a/Mage.Client/src/main/java/mage/client/game/BattlefieldPanel.java b/Mage.Client/src/main/java/mage/client/game/BattlefieldPanel.java index e3c939bae74..66100b900b1 100644 --- a/Mage.Client/src/main/java/mage/client/game/BattlefieldPanel.java +++ b/Mage.Client/src/main/java/mage/client/game/BattlefieldPanel.java @@ -92,7 +92,7 @@ public class BattlefieldPanel extends javax.swing.JLayeredPane { gameUpdateTimer = new Timer(GAME_REDRAW_TIMEOUT_MS, evt -> SwingUtilities.invokeLater(() -> { gameUpdateTimer.stop(); ClientCallback updateMessage = new ClientCallback(ClientCallbackMethod.GAME_REDRAW_GUI, gameId); - MageFrame.getInstance().processCallback(updateMessage); + MageFrame.getInstance().onCallback(updateMessage); })); } diff --git a/Mage.Client/src/main/java/mage/client/game/GamePane.java b/Mage.Client/src/main/java/mage/client/game/GamePane.java index 65fa3db7d82..4ea7e3e2723 100644 --- a/Mage.Client/src/main/java/mage/client/game/GamePane.java +++ b/Mage.Client/src/main/java/mage/client/game/GamePane.java @@ -7,7 +7,7 @@ import javax.swing.*; import mage.client.MagePane; /** - * Game GUI: game panel with scrollbars + * Game GUI: game frame (game panel with scrolls) * * @author BetaSteward_at_googlemail.com */ @@ -31,6 +31,11 @@ public class GamePane extends MagePane { gamePanel.showGame(gameId, playerId, this); } + @Override + public boolean isActiveTable() { + return this.gameId != null; + } + public void cleanUp() { gamePanel.cleanUp(); } diff --git a/Mage.Client/src/main/java/mage/client/game/GamePanel.java b/Mage.Client/src/main/java/mage/client/game/GamePanel.java index e98a78cbf0d..92769bcfe7f 100644 --- a/Mage.Client/src/main/java/mage/client/game/GamePanel.java +++ b/Mage.Client/src/main/java/mage/client/game/GamePanel.java @@ -1469,6 +1469,10 @@ public final class GamePanel extends javax.swing.JPanel { this.feedbackPanel.prepareFeedback(FeedbackMode.QUESTION, question, false, options, true, gameView.getPhase()); } + public boolean isMissGameData() { + return lastGameData.game == null || lastGameData.game.getPlayers().isEmpty(); + } + private void keepLastGameData(int messageId, GameView game, boolean showPlayable, Map options, Set targets) { lastGameData.messageId = messageId; lastGameData.setNewGame(game); diff --git a/Mage.Client/src/main/java/mage/client/game/PlayerPanelExt.java b/Mage.Client/src/main/java/mage/client/game/PlayerPanelExt.java index 5e52a38d539..cae24e1d8ce 100644 --- a/Mage.Client/src/main/java/mage/client/game/PlayerPanelExt.java +++ b/Mage.Client/src/main/java/mage/client/game/PlayerPanelExt.java @@ -585,7 +585,7 @@ public class PlayerPanelExt extends javax.swing.JPanel { resized = ImageHelper.getResizedImage(BufferedImageBuilder.bufferImage(image, BufferedImage.TYPE_INT_ARGB), r); cheat = new JButton(); cheat.setIcon(new ImageIcon(resized)); - cheat.setToolTipText("Cheat button"); + cheat.setToolTipText("Cheat button (activate it on your priority only)"); cheat.addActionListener(e -> btnCheatActionPerformed(e)); // tools button like hints diff --git a/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java b/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java index 396a7d6fd01..729ea7b1755 100644 --- a/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java +++ b/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java @@ -23,7 +23,7 @@ import mage.components.CardInfoPane; import mage.constants.EnlargeMode; import mage.constants.Zone; import mage.util.DebugUtil; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; import mage.view.CardView; import mage.view.PermanentView; import org.apache.log4j.Logger; diff --git a/Mage.Client/src/main/java/mage/client/preference/MagePreferences.java b/Mage.Client/src/main/java/mage/client/preference/MagePreferences.java index d6cd584e429..e3a3c5ed4f3 100644 --- a/Mage.Client/src/main/java/mage/client/preference/MagePreferences.java +++ b/Mage.Client/src/main/java/mage/client/preference/MagePreferences.java @@ -4,6 +4,7 @@ import com.google.common.collect.Sets; import mage.client.MageFrame; import mage.client.util.ClientDefaultSettings; +import java.util.Date; import java.util.Set; import java.util.prefs.BackingStoreException; import java.util.prefs.Preferences; @@ -18,6 +19,7 @@ public final class MagePreferences { private static final String KEY_EMAIL = "email"; private static final String KEY_AUTO_CONNECT = "autoConnect"; private static final String NODE_KEY_IGNORE_LIST = "ignoreListString"; + private static final String NODE_KEY_RESTORE_SESSIONS_LIST = "restoreSessionsListString"; private static String lastServerAddress = ""; private static int lastServerPort = 0; @@ -64,6 +66,7 @@ public final class MagePreferences { return userName; } // For clients older than 1.4.7, userName is stored without a serverAddress prefix. + // TODO: outdated code, can be removed, 2023-12-05 return prefs().get(KEY_USER_NAME, ""); } @@ -143,6 +146,19 @@ public final class MagePreferences { return prefs().node(NODE_KEY_IGNORE_LIST).node(serverAddress); } + private static Preferences restoreSessionsListNode(String serverAddress) { + return prefs().node(NODE_KEY_RESTORE_SESSIONS_LIST).node(serverAddress); + } + + public static void saveRestoreSession(String serverAddress, String userName, String sessionId) { + restoreSessionsListNode(serverAddress).put(userName, sessionId); + restoreSessionsListNode(serverAddress).putLong("lastUpdated", new Date().getTime()); + } + + public static String findRestoreSession(String serverAddress, String userName) { + return restoreSessionsListNode(serverAddress).get(userName, ""); + } + public static void saveLastServer() { lastServerAddress = getServerAddressWithDefault(ClientDefaultSettings.serverName); lastServerPort = getServerPortWithDefault(ClientDefaultSettings.port); diff --git a/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java b/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java index 3f7ad37f667..27ae34e0f6f 100644 --- a/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java +++ b/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java @@ -28,31 +28,52 @@ import java.awt.event.KeyEvent; import java.util.*; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class CallbackClientImpl implements CallbackClient { private static final Logger logger = Logger.getLogger(CallbackClientImpl.class); + + private static final boolean DEBUG_CALLBACK_MESSAGES_LOG = false; // show all callback messages (server commands) + private final MageFrame frame; private final Map lastMessages; + private final Map firstGameData; public CallbackClientImpl(MageFrame frame) { this.frame = frame; this.lastMessages = new HashMap<>(); + this.firstGameData = new HashMap<>(); Arrays.stream(ClientCallbackType.values()).forEach(t -> this.lastMessages.put(t, 0)); } @Override - public synchronized void processCallback(final ClientCallback callback) { + public void onNewConnection() { + // must clean temp data for each new connection + this.lastMessages.clear(); + this.firstGameData.clear(); + } + + @Override + public synchronized void onCallback(final ClientCallback callback) { callback.decompressData(); // put replay related code here SaveObjectUtil.saveObject(callback.getData(), callback.getMethod().toString()); + // reconnect fix with miss data, part 1 of 2 + // on reconnect many events can come in diff order, so init GameView data with first available info + // game_start event can come after game view - must save it here (part 1) before real use in swing thread (part 2) + if (callback.getData() instanceof GameClientMessage) { + firstGameData.putIfAbsent(callback.getObjectId(), (GameClientMessage) callback.getData()); + } + // all GUI related code must be executed in swing thread SwingUtilities.invokeLater(() -> { try { - logger.debug("message " + callback.getMessageId() + " - " + callback.getMethod().getType() + " - " + callback.getMethod()); + if (DEBUG_CALLBACK_MESSAGES_LOG) { + logger.info("message " + callback.getMessageId() + " - " + callback.getMethod().getType() + " - " + callback.getMethod()); + } // process bad connection (events can income in wrong order, so outdated data must be ignored) // - table/dialog events like game start, game end, choose dialog - must be processed anyway @@ -92,6 +113,24 @@ public class CallbackClientImpl implements CallbackClient { TableClientMessage message = (TableClientMessage) callback.getData(); GameManager.instance.setCurrentPlayerUUID(message.getPlayerId()); gameStarted(callback.getMessageId(), message.getGameId(), message.getPlayerId()); + + // reconnect fix with miss data, part 2 of 2 + // START_GAME event can come after GAME_INIT or any other, so must force update with first info + // START_GAME even raises before a real game start, so it hasn't GameView in payload + GamePanel gamePanel = MageFrame.getGame(callback.getObjectId()); + if (gamePanel != null && gamePanel.isMissGameData()) { + GameClientMessage mes = firstGameData.getOrDefault(callback.getObjectId(), null); + if (mes != null) { + logger.warn("Found miss game data, requesting latest info... (possible reason: reconnect)"); + gamePanel.init(callback.getMessageId(), mes.getGameView(), true); + // ask server to resent latest data + // TODO: replace by special async request like requestGameUpdate + SessionHandler.sendPlayerUUID(callback.getObjectId(), UUID.randomUUID()); + } else { + // it's ok, new game come here + // logger.error("Found miss game data, but can't find any usefull info (report to developers)"); + } + } break; } @@ -101,12 +140,6 @@ public class CallbackClientImpl implements CallbackClient { break; } - case START_DRAFT: { - TableClientMessage message = (TableClientMessage) callback.getData(); - draftStarted(callback.getMessageId(), message.getGameId(), message.getPlayerId()); - break; - } - case REPLAY_GAME: { replayGame(callback.getObjectId()); break; @@ -126,7 +159,7 @@ public class CallbackClientImpl implements CallbackClient { ChatMessage message = (ChatMessage) callback.getData(); // Drop messages from ignored users if (message.getUsername() != null && IgnoreList.IGNORED_MESSAGE_TYPES.contains(message.getMessageType())) { - final String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + final String serverAddress = SessionHandler.getSession().getServerHost(); if (IgnoreList.userIsIgnored(serverAddress, message.getUsername())) { break; } @@ -426,6 +459,12 @@ public class CallbackClientImpl implements CallbackClient { break; } + case START_DRAFT: { + TableClientMessage message = (TableClientMessage) callback.getData(); + draftStarted(callback.getMessageId(), message.getGameId(), message.getPlayerId()); + break; + } + case DRAFT_OVER: { MageFrame.removeDraft(callback.getObjectId()); break; @@ -544,7 +583,7 @@ public class CallbackClientImpl implements CallbackClient { null, null, MessageType.USER_INFO, ChatMessage.MessageColor.BLUE); break; case TABLES: - String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + String serverAddress = SessionHandler.getSession().getServerHost(); usedPanel.receiveMessage("", new StringBuilder("Download card images by using the \"Images\" main menu.") .append("
Download icons and symbols by using the \"Symbols\" main menu.") .append("
\\list - show a list of available chat commands.") diff --git a/Mage.Client/src/main/java/mage/client/table/TablesPane.java b/Mage.Client/src/main/java/mage/client/table/TablesPane.java index 5e781aac79b..98a904443cb 100644 --- a/Mage.Client/src/main/java/mage/client/table/TablesPane.java +++ b/Mage.Client/src/main/java/mage/client/table/TablesPane.java @@ -1,4 +1,3 @@ - package mage.client.table; import java.util.UUID; @@ -9,14 +8,14 @@ import mage.client.SessionHandler; import mage.client.plugins.impl.Plugins; /** + * Game GUI: lobby frame * * @author BetaSteward_at_googlemail.com */ public class TablesPane extends MagePane { - /** - * Creates new form TablesPane - */ + UUID roomId = null; + public TablesPane() { boolean initialized = false; if (Plugins.instance.isThemePluginLoaded()) { @@ -47,11 +46,17 @@ public class TablesPane extends MagePane { public void showTables() { UUID roomId = SessionHandler.getSession().getMainRoomId(); if (roomId != null) { - this.setTitle("Tables"); + this.roomId = roomId; + this.setTitle("Server's lobby"); tablesPanel.showTables(roomId); this.repaint(); } + } + @Override + public boolean isActiveTable() { + // it's defalt server lobby, so don't count it as active + return false; } public void hideTables() { diff --git a/Mage.Client/src/main/java/mage/client/table/TablesPanel.java b/Mage.Client/src/main/java/mage/client/table/TablesPanel.java index 2878566b3a2..3399b5cff70 100644 --- a/Mage.Client/src/main/java/mage/client/table/TablesPanel.java +++ b/Mage.Client/src/main/java/mage/client/table/TablesPanel.java @@ -940,7 +940,7 @@ public class TablesPanel extends javax.swing.JPanel { // Hide games of ignored players java.util.List> ignoreListFilterList = new ArrayList<>(); - String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + String serverAddress = SessionHandler.getSession().getServerHost(); final Set ignoreListCopy = IgnoreList.getIgnoredUsers(serverAddress); if (!ignoreListCopy.isEmpty()) { ignoreListFilterList.add(new RowFilter() { @@ -1718,7 +1718,7 @@ public class TablesPanel extends javax.swing.JPanel { options.setRollbackTurnsAllowed(true); options.setQuitRatio(100); options.setMinimumRating(0); - String serverAddress = SessionHandler.getSession().getServerHostname().orElse(""); + String serverAddress = SessionHandler.getSession().getServerHost(); options.setBannedUsers(IgnoreList.getIgnoredUsers(serverAddress)); table = SessionHandler.createTable(roomId, options); diff --git a/Mage.Client/src/main/java/mage/client/tournament/TournamentPane.java b/Mage.Client/src/main/java/mage/client/tournament/TournamentPane.java index e859263632d..c273650a294 100644 --- a/Mage.Client/src/main/java/mage/client/tournament/TournamentPane.java +++ b/Mage.Client/src/main/java/mage/client/tournament/TournamentPane.java @@ -4,24 +4,30 @@ import java.util.UUID; import mage.client.MagePane; /** + * Game GUI: tournament frame * * @author BetaSteward_at_googlemail.com */ public class TournamentPane extends MagePane { - /** - * Creates new form TournamentPane - */ + UUID tournamentId = null; + public TournamentPane() { initComponents(); } public void showTournament(UUID tournamentId) { + this.tournamentId = tournamentId; this.setTitle("Tournament " + tournamentId); this.tournamentPanel.showTournament(tournamentId); this.repaint(); } + @Override + public boolean isActiveTable() { + return this.tournamentId != null; + } + public void removeTournament() { tournamentPanel.cleanUp(); removeFrame(); diff --git a/Mage.Client/src/main/java/mage/client/util/IgnoreList.java b/Mage.Client/src/main/java/mage/client/util/IgnoreList.java index 964887d5542..508f66b2c0b 100644 --- a/Mage.Client/src/main/java/mage/client/util/IgnoreList.java +++ b/Mage.Client/src/main/java/mage/client/util/IgnoreList.java @@ -53,15 +53,15 @@ public final class IgnoreList { } MagePreferences.addIgnoredUser(serverAddress, user); - updateTablesTable(); + updateServerLobbyTables(); return "Added " + user + " to your ignore list on " + serverAddress + " (total: " + getIgnoredUsers(serverAddress).size() + ")"; } - private static void updateTablesTable() { + private static void updateServerLobbyTables() { MageFrame mageFrame = MageFrame.getInstance(); if (mageFrame != null) { - mageFrame.setTableFilter(); + mageFrame.setServerLobbyTablesFilter(); } } @@ -70,7 +70,7 @@ public final class IgnoreList { return usage(serverAddress); } if (MagePreferences.removeIgnoredUser(serverAddress, user)) { - updateTablesTable(); + updateServerLobbyTables(); return "Removed " + user + " from your ignore list on " + serverAddress + " (total: " + getIgnoredUsers(serverAddress).size() + ")"; } else { return "No such user \"" + user + "\" on your ignore list on " + serverAddress + " (total: " + getIgnoredUsers(serverAddress).size() + ")"; diff --git a/Mage.Client/src/main/java/mage/client/util/audio/LinePool.java b/Mage.Client/src/main/java/mage/client/util/audio/LinePool.java index 45df3ad77d6..1d07580b274 100644 --- a/Mage.Client/src/main/java/mage/client/util/audio/LinePool.java +++ b/Mage.Client/src/main/java/mage/client/util/audio/LinePool.java @@ -19,7 +19,7 @@ import javax.sound.sampled.SourceDataLine; import org.apache.log4j.Logger; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; public class LinePool { diff --git a/Mage.Client/src/test/java/mage/client/game/MultiConnectTest.java b/Mage.Client/src/test/java/mage/client/game/MultiConnectTest.java index beab52d5e85..991f167239a 100644 --- a/Mage.Client/src/test/java/mage/client/game/MultiConnectTest.java +++ b/Mage.Client/src/test/java/mage/client/game/MultiConnectTest.java @@ -86,8 +86,13 @@ public class MultiConnectTest { } @Override - public void processCallback(ClientCallback callback) { - logger.info("processCallback"); + public void onNewConnection() { + logger.info("onNewConnection"); + } + + @Override + public void onCallback(ClientCallback callback) { + logger.info("onCallback"); } } diff --git a/Mage.Common/src/main/java/mage/interfaces/MageServer.java b/Mage.Common/src/main/java/mage/interfaces/MageServer.java index 4cd2f64ac32..798156f14dd 100644 --- a/Mage.Common/src/main/java/mage/interfaces/MageServer.java +++ b/Mage.Common/src/main/java/mage/interfaces/MageServer.java @@ -32,7 +32,7 @@ public interface MageServer { boolean authResetPassword(String sessionId, String email, String authToken, String password) throws MageException; - boolean connectUser(String userName, String password, String sessionId, MageVersion version, String userIdStr) throws MageException; + boolean connectUser(String userName, String password, String sessionId, String restoreSessionId, MageVersion version, String userIdStr) throws MageException; boolean connectAdmin(String password, String sessionId, MageVersion version) throws MageException; diff --git a/Mage.Common/src/main/java/mage/interfaces/callback/CallbackClient.java b/Mage.Common/src/main/java/mage/interfaces/callback/CallbackClient.java index d64eabb2b84..5bcabccea28 100644 --- a/Mage.Common/src/main/java/mage/interfaces/callback/CallbackClient.java +++ b/Mage.Common/src/main/java/mage/interfaces/callback/CallbackClient.java @@ -1,12 +1,13 @@ - - package mage.interfaces.callback; /** + * Network: client to process income server commands * * @author BetaSteward_at_googlemail.com */ public interface CallbackClient { - void processCallback(ClientCallback callback); + void onNewConnection(); + + void onCallback(ClientCallback callback); } diff --git a/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallback.java b/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallback.java index 4131c2175fe..6988a14f220 100644 --- a/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallback.java +++ b/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallback.java @@ -2,12 +2,14 @@ package mage.interfaces.callback; import mage.remote.traffic.ZippedObject; import mage.utils.CompressUtil; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; import java.io.Serializable; import java.util.UUID; /** + * Network: server's event to proccess on client side + * * @author BetaSteward_at_googlemail.com */ public class ClientCallback implements Serializable { diff --git a/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackMethod.java b/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackMethod.java index 343ecf52f7b..148efa269c4 100644 --- a/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackMethod.java +++ b/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackMethod.java @@ -1,7 +1,7 @@ package mage.interfaces.callback; /** - * Server's commands to process on client side. Commands can come in un-synced state due bad/slow network + * Network: server's commands to process on client side. Commands can come in un-synced state due bad/slow network *

* Can be: * - critical events (messages, game events, choose dialogs, etc) diff --git a/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackType.java b/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackType.java index 61636256484..124da7856d9 100644 --- a/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackType.java +++ b/Mage.Common/src/main/java/mage/interfaces/callback/ClientCallbackType.java @@ -1,7 +1,7 @@ package mage.interfaces.callback; /** - * Server event type for processing on the client + * Network: server event type for processing on the client * * @author JayDi85 */ diff --git a/Mage.Common/src/main/java/mage/remote/SessionImpl.java b/Mage.Common/src/main/java/mage/remote/SessionImpl.java index a3a597da16b..afbb9edc24e 100644 --- a/Mage.Common/src/main/java/mage/remote/SessionImpl.java +++ b/Mage.Common/src/main/java/mage/remote/SessionImpl.java @@ -2,10 +2,6 @@ package mage.remote; import mage.MageException; import mage.cards.decks.DeckCardLists; -import mage.cards.repository.CardInfo; -import mage.cards.repository.CardRepository; -import mage.cards.repository.ExpansionInfo; -import mage.cards.repository.ExpansionRepository; import mage.constants.ManaType; import mage.constants.PlayerAction; import mage.game.GameException; @@ -18,7 +14,7 @@ import mage.interfaces.callback.ClientCallback; import mage.players.PlayerType; import mage.players.net.UserData; import mage.utils.CompressUtil; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; import mage.view.*; import org.apache.log4j.Logger; import org.jboss.remoting.*; @@ -44,25 +40,31 @@ import java.util.concurrent.TimeUnit; */ public class SessionImpl implements Session { + private static final Logger logger = Logger.getLogger(SessionImpl.class); + + public static final String ADMIN_NAME = "Admin"; // if you change here then change in User too + public static final String KEEP_MY_OLD_SESSION = "keep_my_old_session"; // for disconnects without active session lose (keep tables/games) + private enum SessionState { DISCONNECTED, CONNECTED, CONNECTING, DISCONNECTING, SERVER_STARTING } - private static final Logger logger = Logger.getLogger(SessionImpl.class); - private final MageClient client; - private String sessionId; + private String sessionId = ""; + private String restoreSessionId = ""; private MageServer server; - private Client callbackClient; - private CallbackHandler callbackHandler; + + private Client callbackClient; // real connection with a server + private CallbackHandler callbackHandler; // processing commands from a server + private ServerState serverState; private SessionState sessionState = SessionState.DISCONNECTED; private Connection connection; - private RemotingTask lastRemotingTask = null; + private RemotingTask lastRemotingTask = null; // single task for a server like connect, register, etc private static final int PING_CYCLES = 10; private final LinkedList pingTime = new LinkedList<>(); - private String pingInfo = ""; + private String lastPingInfo = ""; private static boolean debugMode = false; private boolean canceled = false; @@ -82,6 +84,11 @@ public class SessionImpl implements Session { return sessionId; } + @Override + public void setRestoreSessionId(String restoreSessionId) { + this.restoreSessionId = restoreSessionId; + } + // RemotingTask - do server side works in background and return result, can be canceled at any time public abstract class RemotingTask { @@ -140,7 +147,7 @@ public class SessionImpl implements Session { try { Thread.sleep(3000); } catch (InterruptedException e) { - logger.fatal("waiting of error message had failed", e); + logger.fatal("Server not responding, can't get error message from it", e); Thread.currentThread().interrupt(); } } @@ -182,7 +189,7 @@ public class SessionImpl implements Session { showMessageToUser(addMessage + (ex.getMessage() != null ? ex.getMessage() : "")); } catch (MageVersionException ex) { logger.warn("Connect: wrong versions"); - connectStop(false); + connectStop(false, false); if (!canceled) { showMessageToUser(ex.toString()); } @@ -193,14 +200,14 @@ public class SessionImpl implements Session { } catch (Throwable t) { Throwable ex = ThreadUtils.findRootException(t); logger.fatal("Connect: FAIL", t); - connectStop(false); + connectStop(false, false); if (!canceled) { showMessageToUser(ex.toString()); } } finally { lastRemotingTask = null; if (closeConnectionOnFinish) { - connectStop(false); // it's ok on mutiple calls + connectStop(false, false); // it's ok on mutiple calls } } return false; @@ -256,7 +263,7 @@ public class SessionImpl implements Session { if (connection.getAdminPassword() == null) { // for backward compatibility. don't remove twice call - first one does nothing but for version checking - result = server.connectUser(connection.getUsername(), connection.getPassword(), sessionId, client.getVersion(), connection.getUserIdStr()); + result = server.connectUser(connection.getUsername(), connection.getPassword(), sessionId, restoreSessionId, client.getVersion(), connection.getUserIdStr()); } else { result = server.connectAdmin(connection.getAdminPassword(), sessionId, client.getVersion()); } @@ -272,7 +279,7 @@ public class SessionImpl implements Session { throw new MageVersionException(client.getVersion(), serverState.getVersion()); } - if (!connection.getUsername().equals("Admin")) { + if (!connection.getUsername().equals(ADMIN_NAME)) { server.connectSetUserData(connection.getUsername(), sessionId, connection.getUserData(), client.getVersion().toString(), connection.getUserIdStr()); } @@ -288,8 +295,8 @@ public class SessionImpl implements Session { } @Override - public Optional getServerHostname() { - return isConnected() ? Optional.of(connection.getHost()) : Optional.empty(); + public String getServerHost() { + return isConnected() ? connection.getHost() : ""; } @Override @@ -303,9 +310,13 @@ public class SessionImpl implements Session { private boolean doRemoteConnection(final Connection connection) { // connect to server and setup all data, can be canceled + // it's anon connect without any user data (only version check) + + // close current connection if (isConnected()) { - connectStop(true); + connectStop(true, false); } + this.connection = connection; this.canceled = false; sessionState = SessionState.CONNECTING; @@ -435,7 +446,7 @@ public class SessionImpl implements Session { listenerMetadata.put(ConnectionValidator.VALIDATOR_PING_PERIOD, "15000"); listenerMetadata.put(ConnectionValidator.VALIDATOR_PING_TIMEOUT, "13000"); } - callbackClient.connect(new ClientConnectionListener(), listenerMetadata); + callbackClient.connect(new MageClientConnectionListener(), listenerMetadata); Map callbackMetadata = new HashMap<>(); callbackMetadata.put(Bisocket.IS_CALLBACK_SERVER, "true"); @@ -448,11 +459,11 @@ public class SessionImpl implements Session { if (callbackConnectors.size() != 1) { logger.warn("There should be one callback Connector (number existing = " + callbackConnectors.size() + ')'); } - callbackClient.invoke(null); sessionId = callbackClient.getSessionId(); sessionState = SessionState.CONNECTED; + client.onNewConnection(); logger.info("Connect: DONE"); return true; } @@ -468,7 +479,7 @@ public class SessionImpl implements Session { if (result) { return true; } else { - connectStop(false); + connectStop(false, false); return false; } } @@ -507,11 +518,11 @@ public class SessionImpl implements Session { } /** - * @param askForReconnect - true = connection was lost because of error and - * ask the user if they want to try to reconnect + * @param askForReconnect - ask user to reconnect to server (e.g. on connection error) + * @param keepMySessionActive - keep session active for app reconnect/restart, server will close it after few minutes timeout */ @Override - public synchronized void connectStop(boolean askForReconnect) { + public synchronized void connectStop(boolean askForReconnect, boolean keepMySessionActive) { if (isConnected()) { logger.info("Disconnecting..."); sessionState = SessionState.DISCONNECTING; @@ -522,24 +533,37 @@ public class SessionImpl implements Session { try { if (callbackClient != null && callbackClient.isConnected()) { + if (keepMySessionActive) { + // hide real session from a server, so it will be active until timeout + callbackClient.setSessionId(KEEP_MY_OLD_SESSION); + } callbackClient.removeListener(callbackHandler); callbackClient.disconnect(); } - TransporterClient.destroyTransporterClient(server); } catch (Throwable ex) { logger.fatal("Disconnecting FAIL", ex); } if (sessionState == SessionState.DISCONNECTING || sessionState == SessionState.CONNECTING) { + // client side only, so no needs in server disconnection sessionState = SessionState.DISCONNECTED; serverState = null; logger.info("Disconnecting DONE"); if (askForReconnect) { - client.showError("Network error. You have been disconnected from " + connection.getHost()); + client.showError("Network error. Can't connect to " + connection.getHost()); } client.disconnected(askForReconnect); // MageFrame with check to reconnect pingTime.clear(); } + + // clean resources + if (server != null) { + TransporterClient.destroyTransporterClient(server); + server = null; + } + callbackClient = null; + callbackHandler = null; + serverState = null; } @Override @@ -565,7 +589,7 @@ public class SessionImpl implements Session { @Override public void handleCallback(Callback callback) throws HandleCallbackException { try { - client.processCallback((ClientCallback) callback.getCallbackObject()); + client.onCallback((ClientCallback) callback.getCallbackObject()); } catch (Exception ex) { logger.error("handleCallback error", ex); } @@ -573,7 +597,10 @@ public class SessionImpl implements Session { } } - class ClientConnectionListener implements ConnectionListener { + /** + * Network, client side: connection monitoring and error processing + */ + class MageClientConnectionListener implements ConnectionListener { // http://docs.jboss.org/jbossremoting/2.5.3.SP1/html/chapter-connection-failure.html @Override @@ -984,7 +1011,7 @@ public class SessionImpl implements Session { } return null; } - + @Override public boolean setBoosterLoaded(UUID draftId) { try { @@ -1016,7 +1043,6 @@ public class SessionImpl implements Session { @Override public boolean leaveChat(UUID chatId) { -// lock.readLock().lock(); try { if (isConnected() && chatId != null) { server.chatLeave(chatId, sessionId); @@ -1026,8 +1052,6 @@ public class SessionImpl implements Session { handleMageException(ex); } catch (Throwable t) { handleThrowable(t); -// } finally { -// lock.readLock().unlock(); } return false; } @@ -1678,38 +1702,40 @@ public class SessionImpl implements Session { } @Override - public boolean ping() { + public void ping() { try { + // jboss uses lease mechanic for connection check but xmage needs additional data like pings stats // ping must work after login only, all other actions are single call (example: register new user) // sessionId fills on connection // serverState fills on good login - if (isConnected() && sessionId != null && serverState != null) { - long startTime = System.nanoTime(); - if (!server.ping(sessionId, pingInfo)) { - logger.error("Ping failed: " + this.getUserName() + " Session: " + sessionId + " to MAGE server at " + connection.getHost() + ':' + connection.getPort()); - throw new MageException("Ping failed"); - } - pingTime.add(System.nanoTime() - startTime); - long milliSeconds = TimeUnit.MILLISECONDS.convert(pingTime.getLast(), TimeUnit.NANOSECONDS); - String lastPing = milliSeconds > 0 ? milliSeconds + "ms" : "<1ms"; - if (pingTime.size() > PING_CYCLES) { - pingTime.poll(); - } - long sum = 0; - for (Long time : pingTime) { - sum += time; - } - milliSeconds = TimeUnit.MILLISECONDS.convert(sum / pingTime.size(), TimeUnit.NANOSECONDS); - pingInfo = lastPing + " (avg: " + (milliSeconds > 0 ? milliSeconds + "ms" : "<1ms") + ')'; + if (!isConnected() || sessionId == null || serverState == null) { + return; } - return true; + + long startTime = System.nanoTime(); + if (!server.ping(sessionId, lastPingInfo)) { + logger.error("Ping failed: " + this.getUserName() + " Session: " + sessionId + " to MAGE server at " + connection.getHost() + ':' + connection.getPort()); + throw new MageException("Ping failed"); + } + pingTime.add(System.nanoTime() - startTime); + long milliSeconds = TimeUnit.MILLISECONDS.convert(pingTime.getLast(), TimeUnit.NANOSECONDS); + String lastPing = milliSeconds > 0 ? milliSeconds + "ms" : "<1ms"; + if (pingTime.size() > PING_CYCLES) { + pingTime.poll(); + } + long sum = 0; + for (Long time : pingTime) { + sum += time; + } + milliSeconds = TimeUnit.MILLISECONDS.convert(sum / pingTime.size(), TimeUnit.NANOSECONDS); + lastPingInfo = lastPing + " (avg: " + (milliSeconds > 0 ? milliSeconds + "ms" : "<1ms") + ')'; } catch (MageException ex) { handleMageException(ex); - connectStop(true); + connectStop(true, true); } catch (Throwable t) { handleThrowable(t); + connectStop(true, true); } - return false; } @Override diff --git a/Mage.Common/src/main/java/mage/remote/interfaces/Connect.java b/Mage.Common/src/main/java/mage/remote/interfaces/Connect.java index ec1b4e6f788..f3096e83d66 100644 --- a/Mage.Common/src/main/java/mage/remote/interfaces/Connect.java +++ b/Mage.Common/src/main/java/mage/remote/interfaces/Connect.java @@ -2,20 +2,20 @@ package mage.remote.interfaces; import mage.remote.Connection; -import java.util.Optional; - /** * Network: client side commands for a server * - * @author noxx + * @author noxx, JayDi85 */ public interface Connect { String getSessionId(); + void setRestoreSessionId(String restoreSessionId); + String getLastError(); - Optional getServerHostname(); + String getServerHost(); boolean sendAuthRegister(Connection connection); @@ -27,11 +27,11 @@ public interface Connect { boolean connectAbort(); - void connectStop(boolean showMessage); + void connectStop(boolean askForReconnect, boolean keepMySessionActive); void connectReconnect(Throwable throwable); - boolean ping(); + void ping(); boolean isConnected(); diff --git a/Mage.Common/src/main/java/mage/utils/SystemUtil.java b/Mage.Common/src/main/java/mage/utils/SystemUtil.java index b831dc27769..b1273ef4281 100644 --- a/Mage.Common/src/main/java/mage/utils/SystemUtil.java +++ b/Mage.Common/src/main/java/mage/utils/SystemUtil.java @@ -28,7 +28,6 @@ import mage.util.CardUtil; import mage.util.MultiAmountMessage; import mage.util.RandomUtil; -import javax.swing.*; import java.io.File; import java.lang.reflect.Constructor; import java.text.DateFormat; @@ -902,28 +901,4 @@ public final class SystemUtil { } return Arrays.asList(cardSet, cardName); } - - public static void ensureRunInGameThread() { - String name = Thread.currentThread().getName(); - if (!name.startsWith("GAME")) { - // how-to fix: use signal logic to inform a game about new command to execute instead direct execute (see example with WantConcede) - // reason: user responses/commands are received by network/call thread, but must be processed by game thread - throw new IllegalArgumentException("Wrong code usage: game related code must run in GAME thread, but it used in " + name, new Throwable()); - } - } - - public static void ensureRunInCallThread() { - String name = Thread.currentThread().getName(); - if (!name.startsWith("CALL")) { - // how-to fix: something wrong in your code logic - throw new IllegalArgumentException("Wrong code usage: client commands code must run in CALL threads, but used in " + name, new Throwable()); - } - } - - public static void ensureRunInGUISwingThread() { - if (!SwingUtilities.isEventDispatchThread()) { - // hot-to fix: run GUI changeable code by SwingUtilities.invokeLater(() -> {xxx}) - throw new IllegalArgumentException("Wrong code usage: GUI related code must run in SWING thread by SwingUtilities.invokeLater", new Throwable()); - } - } } diff --git a/Mage.Common/src/main/java/mage/view/SimpleCardsView.java b/Mage.Common/src/main/java/mage/view/SimpleCardsView.java index c5c7e7a2c42..7c06d6381c6 100644 --- a/Mage.Common/src/main/java/mage/view/SimpleCardsView.java +++ b/Mage.Common/src/main/java/mage/view/SimpleCardsView.java @@ -17,6 +17,9 @@ public class SimpleCardsView extends LinkedHashMap { public SimpleCardsView() {} public SimpleCardsView(Collection cards, boolean isGameObject) { + if (cards == null) { + return; + } for (Card card: cards) { this.put(card.getId(), new SimpleCardView(card.getId(), card.getExpansionSetCode(), card.getCardNumber(), card.getUsesVariousArt(), isGameObject)); } diff --git a/Mage.Common/src/main/java/mage/view/UserRequestMessage.java b/Mage.Common/src/main/java/mage/view/UserRequestMessage.java index 51cb913d0e9..5e379eadfd4 100644 --- a/Mage.Common/src/main/java/mage/view/UserRequestMessage.java +++ b/Mage.Common/src/main/java/mage/view/UserRequestMessage.java @@ -6,6 +6,8 @@ import java.io.Serializable; import java.util.UUID; /** + * GUI: settings for message window that allows to choose additional action (can be used in game or outside) + * * @author LevelX2 */ public class UserRequestMessage implements Serializable { @@ -14,6 +16,7 @@ public class UserRequestMessage implements Serializable { private final String title; private final String message; + private double windowSizeRatio = 1.0; // increase default window size for big messages or buttons private UUID relatedUserId; private String relatedUserName; private UUID matchId; @@ -39,6 +42,14 @@ public class UserRequestMessage implements Serializable { this.button3Action = null; } + public void setWindowSizeRatio(double windowSizeRatio) { + this.windowSizeRatio = windowSizeRatio; + } + + public double getWindowSizeRatio() { + return this.windowSizeRatio; + } + public void setMatchId(UUID matchId) { this.matchId = matchId; } diff --git a/Mage.Server.Console/src/main/java/mage/server/console/ConnectDialog.java b/Mage.Server.Console/src/main/java/mage/server/console/ConnectDialog.java index 2c2d602901c..caf262e0ff7 100644 --- a/Mage.Server.Console/src/main/java/mage/server/console/ConnectDialog.java +++ b/Mage.Server.Console/src/main/java/mage/server/console/ConnectDialog.java @@ -11,6 +11,7 @@ import java.util.concurrent.ExecutionException; import javax.swing.*; import mage.remote.Connection; import mage.remote.Connection.ProxyType; +import mage.remote.SessionImpl; import org.apache.log4j.Logger; /** @@ -20,6 +21,7 @@ import org.apache.log4j.Logger; public class ConnectDialog extends JDialog { private static final Logger logger = Logger.getLogger(ConnectDialog.class); + private ConsoleFrame console; private Connection connection; private ConnectTask task; @@ -352,7 +354,7 @@ public class ConnectDialog extends JDialog { connection.setHost(this.txtServer.getText()); connection.setPort(Integer.parseInt(this.txtPort.getText())); connection.setAdminPassword(new String(txtPassword.getPassword())); - connection.setUsername("Admin"); + connection.setUsername(SessionImpl.ADMIN_NAME); connection.setProxyType((ProxyType) this.cbProxyType.getSelectedItem()); if (!this.cbProxyType.getSelectedItem().equals(ProxyType.NONE)) { connection.setProxyHost(this.txtProxyServer.getText()); diff --git a/Mage.Server.Console/src/main/java/mage/server/console/ConsoleFrame.java b/Mage.Server.Console/src/main/java/mage/server/console/ConsoleFrame.java index 1251af8cf19..6be2097ec43 100644 --- a/Mage.Server.Console/src/main/java/mage/server/console/ConsoleFrame.java +++ b/Mage.Server.Console/src/main/java/mage/server/console/ConsoleFrame.java @@ -154,10 +154,11 @@ public class ConsoleFrame extends javax.swing.JFrame implements MageClient { }// //GEN-END:initComponents private void btnConnectActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnConnectActionPerformed + if (session.isConnected()) { if (JOptionPane.showConfirmDialog(this, "Are you sure you want to disconnect?", "Confirm disconnect", JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION) { this.consolePanel1.stop(); - session.connectStop(false); + session.connectStop(false, false); } } else { connectDialog.showDialog(this); @@ -243,7 +244,13 @@ public class ConsoleFrame extends javax.swing.JFrame implements MageClient { } @Override - public void processCallback(ClientCallback callback) { + public void onNewConnection() { + // ignore + } + + @Override + public void onCallback(ClientCallback callback) { + // ignore } public void exitApp() { @@ -251,7 +258,7 @@ public class ConsoleFrame extends javax.swing.JFrame implements MageClient { if (JOptionPane.showConfirmDialog(this, "You are currently connected. Are you sure you want to disconnect?", "Confirm disconnect", JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) { return; } - session.connectStop(false); + session.connectStop(false, false); } else { if (JOptionPane.showConfirmDialog(this, "Are you sure you want to exit?", "Confirm exit", JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) { return; diff --git a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java index f285e2ca8ce..b97d187f234 100644 --- a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java +++ b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java @@ -265,7 +265,7 @@ public class HumanPlayer extends PlayerImpl { * Prepare priority player for new feedback, call it for every choose cycle before waitForResponse */ protected void prepareForResponse(Game game) { - SystemUtil.ensureRunInGameThread(); + ThreadUtils.ensureRunInGameThread(); // prepare priority player // on null - it's a discard in cleanaup and other non-user code, so don't change it here at that moment @@ -291,7 +291,7 @@ public class HumanPlayer extends PlayerImpl { * @param game */ protected void waitForResponse(Game game) { - SystemUtil.ensureRunInGameThread(); + ThreadUtils.ensureRunInGameThread(); ; if (isExecutingMacro()) { diff --git a/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java b/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java index bf9adc9a42c..2315544dd7f 100644 --- a/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java +++ b/Mage.Server/src/main/java/mage/server/ChatManagerImpl.java @@ -4,7 +4,6 @@ import mage.cards.repository.CardInfo; import mage.cards.repository.CardRepository; import mage.constants.Constants; import mage.game.Game; -import mage.server.exceptions.UserNotFoundException; import mage.server.game.GameController; import mage.server.managers.ChatManager; import mage.server.managers.ManagerFactory; @@ -56,19 +55,13 @@ public class ChatManagerImpl implements ChatManager { } else { logger.trace("Chat to join not found - chatId: " + chatId + " userId: " + userId); } - - } - - @Override - public void clearUserMessageStorage() { - lastUserMessages.clear(); } @Override public void leaveChat(UUID chatId, UUID userId) { ChatSession chatSession = chatSessions.get(chatId); - if (chatSession != null && chatSession.hasUser(userId)) { - chatSession.kill(userId, DisconnectReason.CleaningUp); + if (chatSession != null && chatSession.hasUser(userId, false)) { + chatSession.disconnectUser(userId, DisconnectReason.DisconnectedByUser); } } @@ -321,41 +314,16 @@ public class ChatManagerImpl implements ChatManager { return false; } - /** - * use mainly for announcing that a user connection was lost or that a user - * has reconnected - * - * @param userId - * @param message - * @param color - * @throws mage.server.exceptions.UserNotFoundException - */ - @Override - public void broadcast(UUID userId, String message, MessageColor color) throws UserNotFoundException { - managerFactory.userManager().getUser(userId).ifPresent(user -> { - getChatSessions() - .stream() - .filter(chat -> chat.hasUser(userId)) - .forEach(session -> session.broadcast(user.getName(), message, color, true, null, MessageType.TALK, null)); - - }); - } - @Override public void sendReconnectMessage(UUID userId) { managerFactory.userManager().getUser(userId).ifPresent(user -> getChatSessions() .stream() - .filter(chat -> chat.hasUser(userId)) + .filter(chat -> chat.hasUser(userId, true)) .forEach(chatSession -> chatSession.broadcast(null, user.getName() + " has reconnected", MessageColor.BLUE, true, null, MessageType.STATUS, null))); } - @Override - public void sendLostConnectionMessage(UUID userId, DisconnectReason reason) { - managerFactory.userManager().getUser(userId).ifPresent(user -> sendMessageToUserChats(userId, user.getName() + " " + reason.getMessage())); - } - /** * Send message to all active waiting/tourney/game chats (but not in main lobby) * @@ -367,11 +335,11 @@ public class ChatManagerImpl implements ChatManager { managerFactory.userManager().getUser(userId).ifPresent(user -> { List chatSessions = getChatSessions().stream() .filter(chat -> !chat.getChatId().equals(managerFactory.gamesRoomManager().getMainChatId())) // ignore main lobby - .filter(chat -> chat.hasUser(userId)) + .filter(chat -> chat.hasUser(userId, true)) .collect(Collectors.toList()); if (chatSessions.size() > 0) { - logger.info("INFORM OPPONENTS by " + user.getName() + ": " + message); + logger.debug("INFORM OPPONENTS by " + user.getName() + ": " + message); chatSessions.forEach(chatSession -> chatSession.broadcast(null, message, MessageColor.BLUE, true, null, MessageType.STATUS, null)); } }); @@ -380,8 +348,8 @@ public class ChatManagerImpl implements ChatManager { @Override public void removeUser(UUID userId, DisconnectReason reason) { for (ChatSession chatSession : getChatSessions()) { - if (chatSession.hasUser(userId)) { - chatSession.kill(userId, reason); + if (chatSession.hasUser(userId, false)) { + chatSession.disconnectUser(userId, reason); } } } @@ -397,4 +365,25 @@ public class ChatManagerImpl implements ChatManager { } } + private void clearUserMessageStorage() { + lastUserMessages.clear(); + } + + @Override + public void checkHealth() { + //logger.info("cheching chats..."); + // TODO: add broken chats check and report (with non existing userId) + + /* + logger.info("total chats: " + chatSessions.size()); + chatSessions.values().forEach(c -> { + logger.info("chat " + c.getInfo() + ", " + c.getChatId()); + c.getClients().forEach((userId, userName) -> { + logger.info(" - " + userName + ", " + userId); + }); + }); + */ + + clearUserMessageStorage(); + } } diff --git a/Mage.Server/src/main/java/mage/server/ChatSession.java b/Mage.Server/src/main/java/mage/server/ChatSession.java index 73ffa28fe77..98685945b9e 100644 --- a/Mage.Server/src/main/java/mage/server/ChatSession.java +++ b/Mage.Server/src/main/java/mage/server/ChatSession.java @@ -10,7 +10,6 @@ import mage.view.ChatMessage.MessageType; import mage.view.ChatMessage.SoundToPlay; import org.apache.log4j.Logger; -import java.text.DateFormat; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -19,89 +18,86 @@ import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class ChatSession { private static final Logger logger = Logger.getLogger(ChatSession.class); - private static final DateFormat timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT); private final ManagerFactory managerFactory; - private final ReadWriteLock lock = new ReentrantReadWriteLock(); + private final ReadWriteLock lock = new ReentrantReadWriteLock(); // TODO: no needs due ConcurrentHashMap usage? - private final ConcurrentMap clients = new ConcurrentHashMap<>(); + private final ConcurrentMap users = new ConcurrentHashMap<>(); // active users + private final Set usersHistory = new HashSet<>(); // all users that was here (need for system messages like connection problem) private final UUID chatId; private final Date createTime; private final String info; public ChatSession(ManagerFactory managerFactory, String info) { this.managerFactory = managerFactory; - chatId = UUID.randomUUID(); + this.chatId = UUID.randomUUID(); this.createTime = new Date(); this.info = info; } public void join(UUID userId) { managerFactory.userManager().getUser(userId).ifPresent(user -> { - if (!clients.containsKey(userId)) { + if (!users.containsKey(userId)) { String userName = user.getName(); final Lock w = lock.writeLock(); w.lock(); try { - clients.put(userId, userName); + users.put(userId, userName); + usersHistory.add(userId); } finally { w.unlock(); } - broadcast(null, userName + " has joined (" + user.getClientVersion() + ')', MessageColor.BLUE, true, null, MessageType.STATUS, null); - logger.trace(userName + " joined chat " + chatId); + broadcast(null, userName + " has joined", MessageColor.BLUE, true, null, MessageType.STATUS, null); } }); } - public void kill(UUID userId, DisconnectReason reason) { + public void disconnectUser(UUID userId, DisconnectReason reason) { + // user will reconnect to all chats, so no needs to keep it + // if you kill session then kill all chats too try { - if (reason == null) { - logger.fatal("User kill without disconnect reason userId: " + userId); - reason = DisconnectReason.Undefined; + String userName = users.getOrDefault(userId, null); + if (userName == null) { + return; } - if (userId != null && clients.containsKey(userId)) { - String userName = clients.get(userId); - if (reason != DisconnectReason.LostConnection) { // for lost connection the user will be reconnected or session expire so no removeUserFromAllTablesAndChat of chat yet - final Lock w = lock.writeLock(); - w.lock(); - try { - clients.remove(userId); - } finally { - w.unlock(); - } - logger.debug(userName + '(' + reason.toString() + ')' + " removed from chatId " + chatId); - } - String message = reason.getMessage(); - if (!message.isEmpty()) { - broadcast(null, userName + message, MessageColor.BLUE, true, null, MessageType.STATUS, null); - } + // remove from chat + final Lock w = lock.writeLock(); + w.lock(); + try { + users.remove(userId); + } finally { + w.unlock(); } - } catch (Exception ex) { - logger.fatal("exception: " + ex.toString()); + logger.debug(userName + " (" + reason + ')' + " removed from chatId " + chatId); + + // inform other users about disconnect (lobby, system tab) + if (!reason.messageForUser.isEmpty()) { + broadcast(null, userName + reason.messageForUser, MessageColor.BLUE, true, null, MessageType.STATUS, null); + } + } catch (Exception e) { + logger.fatal("Chat: disconnecting user catch error: " + e, e); } } - public boolean broadcastInfoToUser(User toUser, String message) { - if (clients.containsKey(toUser.getId())) { + public void broadcastInfoToUser(User toUser, String message) { + if (users.containsKey(toUser.getId())) { toUser.fireCallback(new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, new ChatMessage(null, message, new Date(), null, MessageColor.BLUE, MessageType.USER_INFO, null))); - return true; } - return false; } public boolean broadcastWhisperToUser(User fromUser, User toUser, String message) { - if (clients.containsKey(toUser.getId())) { + if (users.containsKey(toUser.getId())) { toUser.fireCallback(new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, new ChatMessage(fromUser.getName(), message, new Date(), null, MessageColor.YELLOW, MessageType.WHISPER_FROM, SoundToPlay.PlayerWhispered))); - if (clients.containsKey(fromUser.getId())) { + if (users.containsKey(fromUser.getId())) { fromUser.fireCallback(new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, new ChatMessage(toUser.getName(), message, new Date(), null, MessageColor.YELLOW, MessageType.WHISPER_TO, null))); return true; @@ -111,6 +107,10 @@ public class ChatSession { } public void broadcast(String userName, String message, MessageColor color, boolean withTime, Game game, MessageType messageType, SoundToPlay soundToPlay) { + // TODO: is it called by single thread for all users? + // TODO: is it freeze on someone's connection fail/freeze? + // 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()) { Set clientsToRemove = new HashSet<>(); ClientCallback clientCallback = new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId, @@ -119,7 +119,7 @@ public class ChatSession { final Lock r = lock.readLock(); r.lock(); try { - chatUserIds.addAll(clients.keySet()); + chatUserIds.addAll(users.keySet()); } finally { r.unlock(); } @@ -135,12 +135,11 @@ public class ChatSession { final Lock w = lock.readLock(); w.lock(); try { - clients.keySet().removeAll(clientsToRemove); + users.keySet().removeAll(clientsToRemove); } finally { w.unlock(); } } - } } @@ -151,12 +150,12 @@ public class ChatSession { return chatId; } - public boolean hasUser(UUID userId) { - return clients.containsKey(userId); + public boolean hasUser(UUID userId, boolean useHistory) { + return useHistory ? usersHistory.contains(userId) : users.containsKey(userId); } - public ConcurrentMap getClients() { - return clients; + public ConcurrentMap getUsers() { + return users; } public Date getCreateTime() { diff --git a/Mage.Server/src/main/java/mage/server/DisconnectReason.java b/Mage.Server/src/main/java/mage/server/DisconnectReason.java index 7734297bd65..b5c05ec64af 100644 --- a/Mage.Server/src/main/java/mage/server/DisconnectReason.java +++ b/Mage.Server/src/main/java/mage/server/DisconnectReason.java @@ -1,26 +1,24 @@ package mage.server; /** + * Server: possible reasons for disconnection * - * @author LevelX2 + * @author LevelX2, JayDi85 */ public enum DisconnectReason { - LostConnection(" has lost connection"), - BecameInactive(" has become inactive"), - Disconnected(" has left XMage"), - CleaningUp(" [cleaning up]"), - ConnectingOtherInstance(" reconnected and replaced still active old session"), - AdminDisconnect(" was disconnected by the admin"), - SessionExpired(" session expired"), - Undefined(""); + LostConnection(false, " has lost connection"), + DisconnectedByUser(true, " has left XMage"), + DisconnectedByUserButKeepTables(false, " has left XMage for app restart/reconnect"), + DisconnectedByAdmin(true, " was disconnected by admin"), + AnotherUserInstance(false, " disconnected by another user intance"), + AnotherUserInstanceSilent(false, ""), // same user, no need inform in chats + SessionExpired(true, " session expired"); - String message; + final boolean isRemoveUserTables; + final String messageForUser; - DisconnectReason(String message) { - this.message = message; + DisconnectReason(boolean isRemoveUserTables, String messageForUser) { + this.isRemoveUserTables = isRemoveUserTables; + this.messageForUser = messageForUser; } - - public String getMessage() { - return message; - } -} +} \ No newline at end of file diff --git a/Mage.Server/src/main/java/mage/server/MageServerImpl.java b/Mage.Server/src/main/java/mage/server/MageServerImpl.java index 0f738646113..b6d4c23ae51 100644 --- a/Mage.Server/src/main/java/mage/server/MageServerImpl.java +++ b/Mage.Server/src/main/java/mage/server/MageServerImpl.java @@ -3,9 +3,7 @@ package mage.server; import mage.MageException; import mage.cards.decks.DeckCardLists; import mage.cards.decks.DeckValidatorFactory; -import mage.cards.repository.CardInfo; import mage.cards.repository.CardRepository; -import mage.cards.repository.ExpansionInfo; import mage.cards.repository.ExpansionRepository; import mage.constants.Constants; import mage.constants.ManaType; @@ -55,6 +53,7 @@ public class MageServerImpl implements MageServer { private final ManagerFactory managerFactory; private final String adminPassword; private final boolean testMode; + private final boolean detailsMode; private final LinkedHashMap activeAuthTokens = new LinkedHashMap() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { @@ -63,10 +62,11 @@ public class MageServerImpl implements MageServer { } }; - public MageServerImpl(ManagerFactory managerFactory, String adminPassword, boolean testMode) { + public MageServerImpl(ManagerFactory managerFactory, String adminPassword, boolean testMode, boolean detailsMode) { this.managerFactory = managerFactory; this.adminPassword = adminPassword; this.testMode = testMode; + this.detailsMode = detailsMode; this.callExecutor = managerFactory.threadExecutor().getCallExecutor(); ServerMessagesUtil.instance.getMessages(); } @@ -146,18 +146,21 @@ public class MageServerImpl implements MageServer { } @Override - public boolean connectUser(String userName, String password, String sessionId, MageVersion version, String userIdStr) throws MageException { + public boolean connectUser(String userName, String password, String sessionId, String restoreSessionId, MageVersion version, String userIdStr) throws MageException { try { if (version.compareTo(Main.getVersion()) != 0) { - logger.info("MageVersionException: userName=" + userName + ", version=" + version + " sessionId=" + sessionId); + logger.debug("MageVersionException: userName=" + userName + ", version=" + version + " sessionId=" + sessionId); throw new MageVersionException(version, Main.getVersion()); } - return managerFactory.sessionManager().connectUser(sessionId, userName, password, userIdStr); - } catch (MageException ex) { - if (ex instanceof MageVersionException) { - throw ex; + return managerFactory.sessionManager().connectUser(sessionId, restoreSessionId, userName, password, userIdStr, this.detailsMode); + } catch (MageException e) { + if (e instanceof MageVersionException) { + // return wrong version error as is + throw e; + } else { + // all other errors must be logged and hidden under a server error + handleException(e); } - handleException(ex); } return false; } @@ -508,7 +511,6 @@ public class MageServerImpl implements MageServer { public void chatJoin(final UUID chatId, final String sessionId, final String userName) throws MageException { execute("joinChat", sessionId, () -> { managerFactory.sessionManager().getSession(sessionId).ifPresent(session -> { - UUID userId = session.getUserId(); managerFactory.chatManager().joinChat(chatId, userId); }); @@ -1025,7 +1027,7 @@ public class MageServerImpl implements MageServer { @Override public void adminDisconnectUser(final String sessionId, final String userSessionId) throws MageException { execute("adminDisconnectUser", sessionId, - () -> managerFactory.sessionManager().disconnectUser(sessionId, userSessionId), + () -> managerFactory.sessionManager().disconnectAnother(sessionId, userSessionId), true ); } @@ -1049,7 +1051,7 @@ public class MageServerImpl implements MageServer { user.showUserMessage("Admin info", "Your user profile was locked until " + SystemUtil.dateFormat.format(lockUntil) + '.'); user.setLockedUntil(lockUntil); if (user.isConnected()) { - managerFactory.sessionManager().disconnectUser(sessionId, user.getSessionId()); + managerFactory.sessionManager().disconnectAnother(sessionId, user.getSessionId()); } }); }, true); @@ -1064,7 +1066,7 @@ public class MageServerImpl implements MageServer { User user = u.get(); user.setActive(active); if (!user.isActive() && user.isConnected()) { - managerFactory.sessionManager().disconnectUser(sessionId, user.getSessionId()); + managerFactory.sessionManager().disconnectAnother(sessionId, user.getSessionId()); } } else if (authorizedUser != null) { User theUser = new User(managerFactory, userName, "localhost", authorizedUser); @@ -1078,7 +1080,7 @@ public class MageServerImpl implements MageServer { execute("adminToggleActivateUser", sessionId, () -> managerFactory.userManager().getUserByName(userName).ifPresent(user -> { user.setActive(!user.isActive()); if (!user.isActive() && user.isConnected()) { - managerFactory.sessionManager().disconnectUser(sessionId, user.getSessionId()); + managerFactory.sessionManager().disconnectAnother(sessionId, user.getSessionId()); } }), true); } @@ -1086,7 +1088,7 @@ public class MageServerImpl implements MageServer { @Override public void adminEndUserSession(final String sessionId, final String userSessionId) throws MageException { execute("adminEndUserSession", sessionId, - () -> managerFactory.sessionManager().endUserSession(sessionId, userSessionId), + () -> managerFactory.sessionManager().disconnectAnother(sessionId, userSessionId), true ); } diff --git a/Mage.Server/src/main/java/mage/server/Main.java b/Mage.Server/src/main/java/mage/server/Main.java index e9cf4c17030..bbb42fe41fb 100644 --- a/Mage.Server/src/main/java/mage/server/Main.java +++ b/Mage.Server/src/main/java/mage/server/Main.java @@ -1,16 +1,17 @@ package mage.server; import mage.cards.ExpansionSet; +import mage.cards.RateCard; import mage.cards.Sets; import mage.cards.decks.DeckValidatorFactory; import mage.cards.repository.CardScanner; import mage.cards.repository.PluginClassloaderRegistery; import mage.cards.repository.RepositoryUtil; -import mage.cards.RateCard; import mage.game.match.MatchType; import mage.game.tournament.TournamentType; import mage.interfaces.MageServer; import mage.remote.Connection; +import mage.remote.SessionImpl; import mage.server.draft.CubeFactory; import mage.server.game.GameFactory; import mage.server.game.PlayerFactory; @@ -18,7 +19,10 @@ import mage.server.managers.ConfigSettings; import mage.server.managers.ManagerFactory; import mage.server.record.UserStatsRepository; import mage.server.tournament.TournamentFactory; -import mage.server.util.*; +import mage.server.util.ConfigFactory; +import mage.server.util.ConfigWrapper; +import mage.server.util.PluginClassLoader; +import mage.server.util.ServerMessagesUtil; import mage.server.util.config.GamePlugin; import mage.server.util.config.Plugin; import mage.utils.MageVersion; @@ -44,7 +48,7 @@ import java.nio.file.Paths; import java.util.*; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public final class Main { @@ -56,6 +60,8 @@ public final class Main { // priority: default setting -> prop setting -> arg setting private static final String testModeArg = "-testMode="; private static final String testModeProp = "xmage.testMode"; + private static final String detailsModeArg = "-detailsMode="; + private static final String detailsModeProp = "xmage.detailsMode"; private static final String adminPasswordArg = "-adminPassword="; private static final String adminPasswordProp = "xmage.adminPassword"; private static final String configPathProp = "xmage.config.path"; @@ -75,6 +81,7 @@ public final class Main { // - simplified registration and login (no password check); // - debug main menu for GUI and rendering testing (must use -debug arg for client app); private static boolean testMode; + private static boolean detailsMode; public static void main(String[] args) { System.setProperty("java.util.Arrays.useLegacyMergeSort", "true"); @@ -85,6 +92,7 @@ public final class Main { // enable test mode by default for developer build (if you run it from source code) testMode |= version.isDeveloperBuild(); + detailsMode = false; // settings by properties (-Dxxx=yyy from a launcher) if (System.getProperty(testModeProp) != null) { @@ -93,6 +101,9 @@ public final class Main { if (System.getProperty(adminPasswordProp) != null) { adminPassword = SystemUtil.sanitize(System.getProperty(adminPasswordProp)); } + if (System.getProperty(detailsModeProp) != null) { + detailsMode = Boolean.parseBoolean(System.getProperty(detailsModeProp)); + } final String configPath; if (System.getProperty(configPathProp) != null) { configPath = System.getProperty(configPathProp); @@ -108,6 +119,9 @@ public final class Main { adminPassword = arg.replace(adminPasswordArg, ""); adminPassword = SystemUtil.sanitize(adminPassword); } + if (arg.startsWith(detailsModeArg)) { + detailsMode = Boolean.parseBoolean(arg.replace(detailsModeArg, "")); + } } logger.info(String.format("Reading configuration from path=%s", configPath)); @@ -234,7 +248,8 @@ public final class Main { logger.info("Config - max pool size : " + config.getMaxPoolSize()); logger.info("Config - num accp.threads: " + config.getNumAcceptThreads()); logger.info("Config - second.bind port: " + config.getSecondaryBindPort()); - logger.info("Config - auth. activated : " + (config.isAuthenticationActivated() ? "true" : "false")); + logger.info("Config - users registration: " + (config.isAuthenticationActivated() ? "true" : "false")); + logger.info("Config - users anon: " + (!config.isAuthenticationActivated() ? "true" : "false")); logger.info("Config - mailgun api key : " + config.getMailgunApiKey()); logger.info("Config - mailgun domain : " + config.getMailgunDomain()); logger.info("Config - mail smtp Host : " + config.getMailSmtpHost()); @@ -261,7 +276,13 @@ public final class Main { // Parameter: serializationtype => jboss InvokerLocator serverLocator = new InvokerLocator(connection.getURI()); if (!isAlreadyRunning(config, serverLocator)) { - server = new MageTransporterServer(managerFactory, serverLocator, new MageServerImpl(managerFactory, adminPassword, testMode), MageServer.class.getName(), new MageServerInvocationHandler(managerFactory)); + server = new MageTransporterServer( + managerFactory, + serverLocator, + new MageServerImpl(managerFactory, adminPassword, testMode, detailsMode), + MageServer.class.getName(), + new MageServerInvocationHandler(managerFactory) + ); server.start(); logger.info("Started MAGE server - listening on " + connection.toString()); @@ -297,67 +318,75 @@ public final class Main { return false; } - static class ClientConnectionListener implements ConnectionListener { + /** + * Network, server side: connection monitoring and error processing + */ + static class MageServerConnectionListener implements ConnectionListener { private final ManagerFactory managerFactory; - public ClientConnectionListener(ManagerFactory managerFactory) { + public MageServerConnectionListener(ManagerFactory managerFactory) { this.managerFactory = managerFactory; } @Override public void handleConnectionException(Throwable throwable, Client client) { String sessionId = client.getSessionId(); - Optional session = managerFactory.sessionManager().getSession(sessionId); - if (!session.isPresent()) { - logger.trace("Session not found : " + sessionId); - } else { - UUID userId = session.get().getUserId(); - StringBuilder sessionInfo = new StringBuilder(); - Optional user = managerFactory.userManager().getUser(userId); - if (user.isPresent()) { - sessionInfo.append(user.get().getName()).append(" [").append(user.get().getGameInfo()).append(']'); - } else { - sessionInfo.append("[user missing] "); - } - sessionInfo.append(" at ").append(session.get().getHost()).append(" sessionId: ").append(session.get().getId()); - if (throwable instanceof ClientDisconnectedException) { - // Seems like the random diconnects from public server land here and should not be handled as explicit disconnects - // So it should be possible to reconnect to server and continue games if DisconnectReason is set to LostConnection - //managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.Disconnected); - managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection); - logger.info("CLIENT DISCONNECTED - " + sessionInfo); - logger.debug("Stack Trace", throwable); - } else { - managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection); - logger.info("LOST CONNECTION - " + sessionInfo); - if (logger.isDebugEnabled()) { - if (throwable == null) { - logger.debug("- cause: Lease expired"); - } else { - logger.debug(" - cause: " + Session.getBasicCause(throwable).toString()); - } - } - } - + Session session = managerFactory.sessionManager().getSession(sessionId).orElse(null); + if (session == null) { + logger.debug("Connection error, session not found : " + sessionId + " - " + throwable); + return; } + // fill by user's info + StringBuilder sessionInfo = new StringBuilder(); + User user = managerFactory.userManager().getUser(session.getUserId()).orElse(null); + if (user != null) { + sessionInfo.append(user.getName()).append(" [").append(user.getGameInfo()).append(']'); + } else { + sessionInfo.append("[no user]"); + } + sessionInfo.append(" at ").append(session.getHost()).append(", sessionId: ").append(session.getId()); + + // check disconnection reason + // lease ping is inner jboss feature to check connection status + // xmage ping is app's feature to check connection and send additional data like ping statistics + if (throwable instanceof ClientDisconnectedException) { + // client called a disconnect command (full disconnect without tables keep) + // no need to keep session + logger.info("CLIENT DISCONNECTED - " + sessionInfo); + logger.debug("- cause: client called disconnect command"); + managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.DisconnectedByUser, true); + } else if (throwable == null) { + // lease timeout (ping), so server lost connection with a client + // must keep tables + logger.info("LOST CONNECTION - " + sessionInfo); + logger.debug("- cause: lease expired"); + managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection, true); + } else { + // unknown error + // must keep tables + logger.info("LOST CONNECTION - " + sessionInfo); + logger.debug("- cause: unknown error - " + throwable); + managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection, true); + } } } + /** + * Network, server side: data transport layer + */ static class MageTransporterServer extends TransporterServer { protected Connector connector; public MageTransporterServer(ManagerFactory managerFactory, InvokerLocator locator, Object target, String subsystem, MageServerInvocationHandler serverInvocationHandler) throws Exception { super(locator, target, subsystem); - connector.addInvocationHandler("callback", serverInvocationHandler); - connector.setLeasePeriod(managerFactory.configSettings().getLeasePeriod()); - connector.addConnectionListener(new ClientConnectionListener(managerFactory)); - } + connector.addInvocationHandler("callback", serverInvocationHandler); // commands processing - public Connector getConnector() throws Exception { - return connector; + // connection monitoring and errors processing + connector.setLeasePeriod(managerFactory.configSettings().getLeasePeriod()); + connector.addConnectionListener(new MageServerConnectionListener(managerFactory)); } @Override @@ -368,6 +397,9 @@ public final class Main { } } + /** + * Network, server side: commands layer for execute (creates one thread per each client connection) + */ static class MageServerInvocationHandler implements ServerInvocationHandler { private final ManagerFactory managerFactory; @@ -396,6 +428,7 @@ public final class Main { @Override public void setInvoker(ServerInvoker invoker) { + // connection settings ((BisocketServerInvoker) invoker).setSecondaryBindPort(managerFactory.configSettings().getSecondaryBindPort()); ((BisocketServerInvoker) invoker).setBacklog(managerFactory.configSettings().getBacklogSize()); ((BisocketServerInvoker) invoker).setNumAcceptThreads(managerFactory.configSettings().getNumAcceptThreads()); @@ -403,7 +436,10 @@ public final class Main { @Override public void addListener(InvokerCallbackHandler callbackHandler) { - // Called for every client connecting to the server + // on client connect: remember client session + // TODO: add ban by IP here? + // need creates on first call, required by ping + // but can be removed and re-creates on reconnection (if server contains another user instance) ServerInvokerCallbackHandler handler = (ServerInvokerCallbackHandler) callbackHandler; try { String sessionId = handler.getClientSessionId(); @@ -415,8 +451,9 @@ public final class Main { @Override public Object invoke(final InvocationRequest invocation) throws Throwable { + // on command: // called for every client connecting to the server (after add Listener) - + // TODO: add ban by IP here? // save client ip-address String sessionId = invocation.getSessionId(); Map map = invocation.getRequestPayload(); @@ -438,11 +475,17 @@ public final class Main { @Override public void removeListener(InvokerCallbackHandler callbackHandler) { + // on client disconnect: remember client session + // if user want to keep session then sessionId will be faked here ServerInvokerCallbackHandler handler = (ServerInvokerCallbackHandler) callbackHandler; String sessionId = handler.getClientSessionId(); - managerFactory.sessionManager().disconnect(sessionId, DisconnectReason.Disconnected); + DisconnectReason reason = DisconnectReason.DisconnectedByUser; + if (SessionImpl.KEEP_MY_OLD_SESSION.equals(sessionId)) { + // no need in additional code, server will keep original session until inactive timeout + reason = DisconnectReason.DisconnectedByUserButKeepTables; + } + managerFactory.sessionManager().disconnect(sessionId, reason, true); } - } private static Class loadPlugin(Plugin plugin) { diff --git a/Mage.Server/src/main/java/mage/server/MainManagerFactory.java b/Mage.Server/src/main/java/mage/server/MainManagerFactory.java index 753a2810f66..38685ec311e 100644 --- a/Mage.Server/src/main/java/mage/server/MainManagerFactory.java +++ b/Mage.Server/src/main/java/mage/server/MainManagerFactory.java @@ -7,9 +7,25 @@ import mage.server.game.ReplayManagerImpl; import mage.server.managers.*; import mage.server.tournament.TournamentManagerImpl; import mage.server.util.ThreadExecutorImpl; +import org.apache.log4j.Logger; +import java.util.concurrent.TimeUnit; + +/** + * @author Burato, JayDi85 + */ public class MainManagerFactory implements ManagerFactory { + private final Logger logger = Logger.getLogger(MainManagerFactory.class); + + // defines how often checking process should be run on server (in minutes) + // TODO: WARNING, it's can be very buggy but very rare (quit players for no reason, e.g. empty deck bug in sideboard) + // main reason - health code can run in the moment of game move from one stage to another (e.g. on sideboarding prepare) + // TODO: add debug menu like "call server health in 10 seconds" + // TODO: add debug menu like "call server side disconnect in 10 seconds" + // TODO: add debug menu like "call client side disconnect in 10 seconds" + private static final int SERVER_HEALTH_CHECK_TIMEOUT_MINS = 10; + private final ConfigSettings configSettings; private final ThreadExecutor threadExecutor; private final ChatManager chatManager; @@ -24,7 +40,6 @@ public class MainManagerFactory implements ManagerFactory { private final UserManager userManager; private final TournamentManager tournamentManager; - public MainManagerFactory(ConfigSettings configSettings) { this.configSettings = configSettings; // ThreadExecutorImpl, MailClientImpl and MailGunClient depend only on the config, so they are initialised first @@ -47,6 +62,7 @@ public class MainManagerFactory implements ManagerFactory { this.gamesRoomManager = gamesRoomManager; this.tableManager = tableManager; this.userManager = userManager; + // execute the initialisation block of the relevant manager (they start the executor services) startThreads(gamesRoomManager, tableManager, userManager); } @@ -55,6 +71,21 @@ public class MainManagerFactory implements ManagerFactory { userManager.init(); tableManager.init(); gamesRoomManager.init(); + + threadExecutor().getServerHealthExecutor().scheduleAtFixedRate(() -> { + try { + //logger.info("---"); + //logger.info("Server health check started"); + this.tableManager().checkHealth(); + this.chatManager().checkHealth(); + this.userManager().checkHealth(); + this.sessionManager().checkHealth(); + } catch (Exception ex) { + logger.fatal("Server health check: catch unknown error - " + ex, ex); + } + //logger.info("Server health check end"); + //logger.info("---"); + }, SERVER_HEALTH_CHECK_TIMEOUT_MINS, SERVER_HEALTH_CHECK_TIMEOUT_MINS, TimeUnit.MINUTES); } @Override diff --git a/Mage.Server/src/main/java/mage/server/Session.java b/Mage.Server/src/main/java/mage/server/Session.java index 37d4d59108e..e928e6161fb 100644 --- a/Mage.Server/src/main/java/mage/server/Session.java +++ b/Mage.Server/src/main/java/mage/server/Session.java @@ -9,8 +9,9 @@ import mage.players.net.UserGroup; import mage.server.game.GamesRoom; import mage.server.managers.ConfigSettings; import mage.server.managers.ManagerFactory; -import mage.utils.SystemUtil; import mage.util.RandomUtil; +import mage.util.ThreadUtils; +import mage.utils.SystemUtil; import org.apache.log4j.Logger; import org.jboss.remoting.callback.AsynchInvokerCallbackHandler; import org.jboss.remoting.callback.Callback; @@ -27,16 +28,41 @@ import java.util.regex.Pattern; import static mage.server.DisconnectReason.LostConnection; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class Session { private static final Logger logger = Logger.getLogger(Session.class); + private static final Pattern alphabetsPattern = Pattern.compile("[a-zA-Z]"); private static final Pattern digitsPattern = Pattern.compile("[0-9]"); public static final String REGISTRATION_DISABLED_MESSAGE = "Registration has been disabled on the server. You can use any name and empty password to login."; + // Connection and reconnection logic: + // - server allows only one user intance (user name for search, userId for memory/db store) + // - if same user connected then all other user's intances must be removed (disconnected) + // - for auth mode: identify intance by userId (must disconnect all other clients with same user name) + // - for anon mode: identify intance by unique mark like IP/host (must disconnect all other clients with same user name + IP) + // - for any mode: optional identify by restore session (must disconnect all other clients with same user name) + // - TODO: implement client id mark instead host like md5(mac, os, somefolders) + // - if server can't remove another user intance then restrict to connect (example: anon user connected, but there is another anon with diff IP) + private static final boolean ANON_IDENTIFY_BY_HOST = true; // for anon mode only: true - kick all other users with same IP; false - keep first connected user + + // async data transfer for all callbacks (transfer of game updates from server to client): + // - pros: + // * SIGNIFICANT performance boost and pings (yep, that's true); + // * no freeze with disconnected/slow watchers/players; + // - cons: + // * data re-sync? TODO: need research, possible problem: outdated battlefield, need click on avatar + // * what about slow connection or big pings? TODO: need research, possible problem: too many re-sync messages in client logs, outdated battlefield, wrong answers + // * what about disconnected players? TODO: need research, possible problem: server can't detect disconnected players too fast + // * what about too many requests to freezed players (too much threads)? + // TODO: need research, possible problem: if something bad with server connection then it can generates + // too many requests to a client and can cause threads overflow/limit (example: watch AI only games?); + // visualvm can help with threads monitor + private static final boolean SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION = false; // TODO: enable after full research + private final ManagerFactory managerFactory; private final String sessionId; private UUID userId; @@ -60,7 +86,7 @@ public class Session { this.callBackLock = new ReentrantLock(); } - public String registerUser(String userName, String password, String email) throws MageException { + public String registerUser(String userName, String password, String email) { if (!managerFactory.configSettings().isAuthenticationActivated()) { String returnMessage = REGISTRATION_DISABLED_MESSAGE; sendErrorMessageToClient(returnMessage); @@ -119,7 +145,7 @@ public class Session { return null; } } - + private String validateUserNameLength(String userName) { ConfigSettings config = managerFactory.configSettings(); if (userName.length() < config.getMinUserNameLength()) { @@ -138,12 +164,10 @@ public class Session { } private String validateUserName(String userName) { - // return error message or null on good name - if (userName.equals("Admin")) { - // virtual user for admin console - return "User name Admin already in use"; + if (userName.equals(User.ADMIN_NAME)) { + return "User name already in use"; } - + String returnMessage = validateUserNameLength(userName); if (returnMessage != null) { return returnMessage; @@ -195,31 +219,30 @@ public class Session { return null; } - public String connectUser(String userName, String password) throws MageException { - String returnMessage = validateUserNameLength(userName); - if (returnMessage != null) { - sendErrorMessageToClient(returnMessage); - return returnMessage; + public String connectUser(String userName, String password, String restoreSessionId) throws MageException { + // check username + String errorMessage = validateUserNameLength(userName); + if (errorMessage != null) { + return errorMessage; } - returnMessage = connectUserHandling(userName, password); - if (returnMessage != null) { - try { - Thread.sleep(3000); - } catch (InterruptedException e) { - logger.fatal("waiting of error message had failed", e); - Thread.currentThread().interrupt(); - } - sendErrorMessageToClient(returnMessage); - } - return returnMessage; + + // check auth/anon + errorMessage = connectUserHandling(userName, password, restoreSessionId); + return errorMessage; } public boolean isLocked() { return lock.isLocked(); } - public String connectUserHandling(String userName, String password) throws MageException { + /** + * Auth user on server (link current session with server's user) + * Return null on good or error message on bad + */ + public String connectUserHandling(String userName, String password, String restoreSessionId) { this.isAdmin = false; + + // find auth user AuthorizedUser authorizedUser = null; if (managerFactory.configSettings().isAuthenticationActivated()) { authorizedUser = AuthorizedUserRepository.getInstance().getByName(userName); @@ -247,46 +270,95 @@ public class Session { } } - Optional selectUser = managerFactory.userManager().createUser(userName, host, authorizedUser); - boolean reconnect = false; - if (!selectUser.isPresent()) { - // user already connected - selectUser = managerFactory.userManager().getUserByName(userName); - if (selectUser.isPresent()) { - User user = selectUser.get(); - // If authentication is not activated, check the identity using IP address. - if (managerFactory.configSettings().isAuthenticationActivated() || user.getHost().equals(host)) { - user.updateLastActivity(null); // minimizes possible expiration - this.userId = user.getId(); - if (user.getSessionId().isEmpty()) { - logger.info("Reconnecting session for " + userName); - reconnect = true; - } else { - //disconnect previous session - logger.info("Disconnecting another user instance: " + userName); - managerFactory.sessionManager().disconnect(user.getSessionId(), DisconnectReason.ConnectingOtherInstance); + // create new user instance (auth or anon) + boolean isReconnection = false; + User newUser = managerFactory.userManager().createUser(userName, host, authorizedUser).orElse(null); + + // if user instance already exists then keep only one instance + if (newUser == null) { + User anotherUser = managerFactory.userManager().getUserByName(userName).orElse(null); + if (anotherUser != null) { + boolean canDisconnectAuthDueAnotherInstance = managerFactory.configSettings().isAuthenticationActivated(); + boolean canDisconnectAnonDueSameHost = !managerFactory.configSettings().isAuthenticationActivated() + && ANON_IDENTIFY_BY_HOST + && Objects.equals(anotherUser.getHost(), host); + boolean canDisconnectAnyDueSessionRestore = Objects.equals(restoreSessionId, anotherUser.getRestoreSessionId()); + if (canDisconnectAuthDueAnotherInstance + || canDisconnectAnonDueSameHost + || canDisconnectAnyDueSessionRestore) { + anotherUser.updateLastActivity(null); // minimizes possible expiration + + // disconnect another user instance, but keep all active tables + if (!anotherUser.getSessionId().isEmpty()) { + // do not use DisconnectReason.Disconnected here - it will remove user from all tables, + // but it must remove only session without any tables + String instanceReason = canDisconnectAnyDueSessionRestore ? " (session reconnect)" + : canDisconnectAnonDueSameHost ? " (same host)" + : canDisconnectAuthDueAnotherInstance ? " (same user)" : ""; + String mes = ""; + mes += String.format("Disconnecting another user instance for %s (reason: %s)", userName, instanceReason); + if (logger.isDebugEnabled()) { + mes += String.format("\n - reason: auth (%s), anon host (%s), any session (%s)", + (canDisconnectAuthDueAnotherInstance ? "yes" : "no"), + (canDisconnectAnonDueSameHost ? "yes" : "no"), + (canDisconnectAnyDueSessionRestore ? "yes" : "no") + ); + mes += String.format("\n - sessionId: %s => %s", anotherUser.getSessionId(), sessionId); + mes += String.format("\n - name: %s => %s", anotherUser.getName(), userName); + mes += String.format("\n - host: %s => %s", anotherUser.getHost(), host); + } + logger.info(mes); + + // kill another instance + DisconnectReason reason = DisconnectReason.AnotherUserInstance; + if (ANON_IDENTIFY_BY_HOST && Objects.equals(anotherUser.getHost(), host)) { + // if user reconnects by itself then must hide another instance message + reason = DisconnectReason.AnotherUserInstanceSilent; + } + + managerFactory.userManager().disconnect(anotherUser.getId(), reason); } + isReconnection = true; + newUser = anotherUser; } else { - return "User name " + userName + " already in use (or your IP address changed)"; + return "User " + userName + " already connected or your IP address changed - try another user"; } } else { // code never goes here return "Can't find connected user name " + userName; } } - User user = selectUser.get(); - if (!managerFactory.userManager().connectToSession(sessionId, user.getId())) { - return "Error connecting " + userName; + + // link user with current session + newUser.setRestoreSessionId(""); + this.userId = newUser.getId(); + if (!managerFactory.userManager().connectToSession(sessionId, this.userId)) { + return "Error link user " + userName + " with session " + sessionId; } - this.userId = user.getId(); - if (reconnect) { // must be connected to receive the message - Optional room = managerFactory.gamesRoomManager().getRoom(managerFactory.gamesRoomManager().getMainRoomId()); - if (!room.isPresent()) { - logger.warn("main room not found"); // after server restart users try to use old rooms on reconnect - return null; - } - managerFactory.chatManager().joinChat(room.get().getChatId(), userId); - managerFactory.chatManager().sendReconnectMessage(userId); + + // connect to lobby (other chats must be joined from a client side on table panel creating process) + GamesRoom lobby = managerFactory.gamesRoomManager().getRoom(managerFactory.gamesRoomManager().getMainRoomId()).orElse(null); + if (lobby != null) { + managerFactory.chatManager().joinChat(lobby.getChatId(), this.userId); + } else { + // TODO: outdated code? Need research server logs and fix or delete it, 2023-12-04 + logger.warn("main room not found"); // after server restart users try to use old rooms on reconnect for some reason + } + + // all fine + newUser.setUserState(User.UserState.Connected); + newUser.setRestoreSessionId(newUser.getSessionId()); + + // restore all active tables + // run in diff thread, so user will be connected anyway (e.g. on some errors in onReconnect) + final User reconnectUser = newUser; + if (isReconnection) { + managerFactory.threadExecutor().getCallExecutor().execute(reconnectUser::onReconnect); + } + + // inform about reconnection + if (isReconnection) { + managerFactory.chatManager().sendReconnectMessage(this.userId); } return null; @@ -294,13 +366,13 @@ public class Session { public void connectAdmin() { this.isAdmin = true; - User user = managerFactory.userManager().createUser("Admin", host, null).orElse( - managerFactory.userManager().getUserByName("Admin").get()); + User user = managerFactory.userManager().createUser(User.ADMIN_NAME, host, null) + .orElse(managerFactory.userManager().getUserByName(User.ADMIN_NAME).get()); UserData adminUserData = UserData.getDefaultUserDataView(); adminUserData.setGroupId(UserGroup.ADMIN.getGroupId()); user.setUserData(adminUserData); if (!managerFactory.userManager().connectToSession(sessionId, user.getId())) { - logger.info("Error connecting Admin!"); + logger.info("Error connecting as admin"); } else { user.setUserState(User.UserState.Connected); } @@ -354,67 +426,39 @@ public class Session { return sessionId; } - // because different threads can activate this - public void userLostConnection() { - Optional _user = managerFactory.userManager().getUser(userId); - if (!_user.isPresent()) { - return; //user was already disconnected by other thread - } - User user = _user.get(); - if (!user.isConnected()) { - return; - } - if (!user.getSessionId().equals(sessionId)) { - // user already reconnected with another instance - logger.info("OLD SESSION IGNORED - " + user.getName()); - } else { - // logger.info("LOST CONNECTION - " + user.getName() + " id: " + userId); - } - } - - public void kill(DisconnectReason reason) { - boolean lockSet = false; - try { - if (lock.tryLock(5000, TimeUnit.MILLISECONDS)) { - lockSet = true; - logger.debug("SESSION LOCK SET sessionId: " + sessionId); - } else { - logger.error("SESSION LOCK - kill: userId " + userId); - } - managerFactory.userManager().removeUserFromAllTablesAndChat(userId, reason); - } catch (InterruptedException ex) { - logger.error("SESSION LOCK - kill: userId " + userId, ex); - } finally { - if (lockSet) { - lock.unlock(); - logger.debug("SESSION LOCK UNLOCK sessionId: " + sessionId); - - } - } - - } - + /** + * Send event/command to the client + */ public void fireCallback(final ClientCallback call) { - boolean lockSet = false; + boolean lockSet = false; // TODO: research about locks, why it here? 2023-12-06 try { if (valid && callBackLock.tryLock(50, TimeUnit.MILLISECONDS)) { call.setMessageId(messageId.incrementAndGet()); lockSet = true; Callback callback = new Callback(call); - callbackHandler.handleCallbackOneway(callback); + callbackHandler.handleCallbackOneway(callback, SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION); } } catch (InterruptedException ex) { - logger.warn("SESSION LOCK - fireCallback - userId: " + userId + " messageId: " + call.getMessageId(), ex); + // already sending another command (connection problem?) + if (call.getMethod().equals(ClientCallbackMethod.GAME_INIT) + || call.getMethod().equals(ClientCallbackMethod.START_GAME)) { + // it's ok use case, user has connection problem so can't send game init (see sendInfoAboutPlayersNotJoinedYetAndTryToFixIt) + } else { + logger.warn("SESSION LOCK, possible connection problem - fireCallback - userId: " + userId + " messageId: " + call.getMessageId(), ex); + } } catch (HandleCallbackException ex) { + // something wrong, maybe connection problem + logger.warn("SESSION CALLBACK EXCEPTION - " + ThreadUtils.findRootException(ex) + ", userId " + userId + ", messageId: " + call.getMessageId(), ex); + + // do not send data anymore (user must reconnect) this.valid = false; - managerFactory.userManager().getUser(userId).ifPresent(user -> { - user.setUserState(User.UserState.Disconnected); - logger.warn("SESSION CALLBACK EXCEPTION - " + user.getName() + " userId " + userId + " messageId: " + call.getMessageId() + " - cause: " + getBasicCause(ex).toString()); - logger.trace("Stack trace:", ex); - managerFactory.sessionManager().disconnect(sessionId, LostConnection); - }); - } catch (Exception ex) { - logger.warn("Unspecific exception:", ex); + managerFactory.sessionManager().disconnect(sessionId, LostConnection, true); + } catch (Throwable ex) { + logger.error("SESSION CALLBACK UNKNOWN EXCEPTION - " + ThreadUtils.findRootException(ex) + ", userId " + userId + ", messageId: " + call.getMessageId(), ex); + + // do not send data anymore (user must reconnect) + this.valid = false; + managerFactory.sessionManager().disconnect(sessionId, LostConnection, true); } finally { if (lockSet) { callBackLock.unlock(); diff --git a/Mage.Server/src/main/java/mage/server/SessionManagerImpl.java b/Mage.Server/src/main/java/mage/server/SessionManagerImpl.java index be81cedcbf1..a02bc605e63 100644 --- a/Mage.Server/src/main/java/mage/server/SessionManagerImpl.java +++ b/Mage.Server/src/main/java/mage/server/SessionManagerImpl.java @@ -2,8 +2,8 @@ package mage.server; import mage.MageException; import mage.players.net.UserData; -import mage.server.managers.SessionManager; import mage.server.managers.ManagerFactory; +import mage.server.managers.SessionManager; import org.apache.log4j.Logger; import org.jboss.remoting.callback.InvokerCallbackHandler; @@ -12,7 +12,9 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; /** - * @author BetaSteward_at_googlemail.com + * Server: manage all connections (first connection, after auth, after anon) + * + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class SessionManagerImpl implements SessionManager { @@ -27,18 +29,7 @@ public class SessionManagerImpl implements SessionManager { @Override public Optional getSession(@Nonnull String sessionId) { - Session session = sessions.get(sessionId); - if (session == null) { - logger.trace("Session with sessionId " + sessionId + " is not found"); - return Optional.empty(); - } - if (session.getUserId() != null && !managerFactory.userManager().getUser(session.getUserId()).isPresent()) { - logger.error("User for session " + sessionId + " with userId " + session.getUserId() + " is missing. Session removed."); - // can happen if user from same host signs in multiple time with multiple clients, after they disconnect with one client - disconnect(sessionId, DisconnectReason.ConnectingOtherInstance, session); // direct disconnect - return Optional.empty(); - } - return Optional.of(session); + return Optional.ofNullable(sessions.getOrDefault(sessionId, null)); } @Override @@ -67,18 +58,33 @@ public class SessionManagerImpl implements SessionManager { } @Override - public boolean connectUser(String sessionId, String userName, String password, String userIdStr) throws MageException { + public boolean connectUser(String sessionId, String restoreSessionId, String userName, String password, String userInfo, boolean detailsMode) throws MageException { Session session = sessions.get(sessionId); if (session != null) { - String returnMessage = session.connectUser(userName, password); - if (returnMessage == null) { - logger.info(userName + " connected to server"); + String errorMessage = session.connectUser(userName, password, restoreSessionId); + if (errorMessage == null) { + logger.info(userName + " connected to server by sessionId " + sessionId + + (restoreSessionId.isEmpty() ? "" : ", restoreSessionId " + restoreSessionId)); + if (detailsMode) { + logger.info("- details: " + userInfo); + } logger.debug("- userId: " + session.getUserId()); logger.debug("- sessionId: " + sessionId); + logger.debug("- restoreSessionId: " + restoreSessionId); logger.debug("- host: " + session.getHost()); return true; } else { - logger.debug(userName + " not connected: " + returnMessage); + logger.debug(userName + " not connected: " + errorMessage); + + // send error to client side + try { + // ddos/bruteforce protection + Thread.sleep(3000); + } catch (InterruptedException e) { + logger.fatal("waiting of error message had failed", e); + Thread.currentThread().interrupt(); + } + session.sendErrorMessageToClient(errorMessage); } } else { logger.error(userName + " tried to connect with no sessionId"); @@ -106,46 +112,27 @@ public class SessionManagerImpl implements SessionManager { } @Override - public void disconnect(String sessionId, DisconnectReason reason) { - disconnect(sessionId, reason, null); - } - - @Override - public void disconnect(String sessionId, DisconnectReason reason, Session directSession) { - if (directSession == null) { - // find real session to disconnects - getSession(sessionId).ifPresent(session -> { - if (!isValidSession(sessionId)) { - // session was removed meanwhile by another thread so we can return - return; - } - logger.debug("DISCONNECT " + reason.toString() + " - sessionId: " + sessionId); - sessions.remove(sessionId); - switch (reason) { - case AdminDisconnect: - session.kill(reason); - break; - case ConnectingOtherInstance: - case Disconnected: // regular session end or wrong client version - managerFactory.userManager().disconnect(session.getUserId(), reason); - break; - case SessionExpired: // session ends after no reconnect happens in the defined time span - break; - case LostConnection: // user lost connection - session expires countdown starts - session.userLostConnection(); - managerFactory.userManager().disconnect(session.getUserId(), reason); - break; - default: - logger.trace("endSession: unexpected reason " + reason.toString() + " - sessionId: " + sessionId); - } - }); - } else { - // direct session to disconnects - sessions.remove(sessionId); - directSession.kill(reason); + public void disconnect(String sessionId, DisconnectReason reason, boolean checkUserDisconnection) { + Session session = getSession(sessionId).orElse(null); + if (session == null) { + return; } - } + if (!isValidSession(sessionId)) { + logger.info("DISCONNECT session, already invalid: " + reason + " - sessionId: " + sessionId); + // session was removed meanwhile by another thread + // TODO: otudated code? If no logs on server then remove that code, 2023-12-06 + return; + } + + if (checkUserDisconnection) { + managerFactory.userManager().getUser(session.getUserId()).ifPresent(user -> { + user.onLostConnection(reason); + }); + } + + sessions.remove(sessionId); + } /** * Admin requested the disconnect of a user @@ -154,36 +141,24 @@ public class SessionManagerImpl implements SessionManager { * @param userSessionId */ @Override - public void disconnectUser(String sessionId, String userSessionId) { + public void disconnectAnother(String sessionId, String userSessionId) { if (!checkAdminAccess(sessionId)) { return; } - getUserFromSession(sessionId).ifPresent(admin -> { - Optional u = getUserFromSession(userSessionId); - if (u.isPresent()) { - User user = u.get(); - user.showUserMessage("Admin action", "Your session was disconnected by admin"); - admin.showUserMessage("Admin result", "User " + user.getName() + " was disconnected"); - disconnect(userSessionId, DisconnectReason.AdminDisconnect); - } else { - admin.showUserMessage("Admin result", "User with sessionId " + userSessionId + " could not be found"); - } - }); + + User admin = getUserFromSession(sessionId).orElse(null); + User user = getUserFromSession(userSessionId).orElse(null); + if (admin == null || user == null) { + return; + } + + user.showUserMessage("Admin action", "Your session was disconnected by admin"); + disconnect(userSessionId, DisconnectReason.DisconnectedByAdmin, true); + admin.showUserMessage("Admin result", "User " + user.getName() + " was disconnected"); } private Optional getUserFromSession(String sessionId) { - return getSession(sessionId) - .flatMap(s -> managerFactory.userManager().getUser(s.getUserId())); - - } - - @Override - public void endUserSession(String sessionId, String userSessionId) { - if (!checkAdminAccess(sessionId)) { - return; - } - - disconnect(userSessionId, DisconnectReason.AdminDisconnect); + return getSession(sessionId).flatMap(s -> managerFactory.userManager().getUser(s.getUserId())); } @Override @@ -233,4 +208,10 @@ public class SessionManagerImpl implements SessionManager { } session.sendErrorMessageToClient(message); } + + @Override + public void checkHealth() { + //logger.info("Checking sessions..."); + // TODO: add lone sessions check and report (with lost user) + } } diff --git a/Mage.Server/src/main/java/mage/server/TableManagerImpl.java b/Mage.Server/src/main/java/mage/server/TableManagerImpl.java index c71ea8f7970..75206e9d49c 100644 --- a/Mage.Server/src/main/java/mage/server/TableManagerImpl.java +++ b/Mage.Server/src/main/java/mage/server/TableManagerImpl.java @@ -14,8 +14,8 @@ import mage.game.tournament.TournamentOptions; import mage.game.tournament.TournamentPlayer; import mage.players.PlayerType; import mage.server.game.GameController; -import mage.server.managers.TableManager; import mage.server.managers.ManagerFactory; +import mage.server.managers.TableManager; import org.apache.log4j.Logger; import java.text.DateFormat; @@ -23,9 +23,6 @@ import java.text.SimpleDateFormat; import java.util.*; import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -34,7 +31,6 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; * @author BetaSteward_at_googlemail.com */ public class TableManagerImpl implements TableManager { - protected final ScheduledExecutorService expireExecutor = Executors.newSingleThreadScheduledExecutor(); private final ManagerFactory managerFactory; private final Logger logger = Logger.getLogger(TableManagerImpl.class); @@ -46,22 +42,11 @@ public class TableManagerImpl implements TableManager { private final ConcurrentHashMap tables = new ConcurrentHashMap<>(); private final ReadWriteLock tablesLock = new ReentrantReadWriteLock(); - // defines how often checking process should be run on server (in minutes) - private static final int TABLE_HEALTH_CHECK_TIMEOUT_MINS = 10; - public TableManagerImpl(ManagerFactory managerFactory) { this.managerFactory = managerFactory; } public void init() { - expireExecutor.scheduleAtFixedRate(() -> { - try { - managerFactory.chatManager().clearUserMessageStorage(); - checkTableHealthState(); - } catch (Exception ex) { - logger.fatal("Check table health state job error:", ex); - } - }, TABLE_HEALTH_CHECK_TIMEOUT_MINS, TABLE_HEALTH_CHECK_TIMEOUT_MINS, TimeUnit.MINUTES); } @Override @@ -426,7 +411,7 @@ public class TableManagerImpl implements TableManager { List chatSessions = managerFactory.chatManager().getChatSessions(); logger.debug("------- ChatSessions: " + chatSessions.size() + " ----------------------------------"); for (ChatSession chatSession : chatSessions) { - logger.debug(chatSession.getChatId() + " " + formatter.format(chatSession.getCreateTime()) + ' ' + chatSession.getInfo() + ' ' + chatSession.getClients().values().toString()); + logger.debug(chatSession.getChatId() + " " + formatter.format(chatSession.getCreateTime()) + ' ' + chatSession.getInfo() + ' ' + chatSession.getUsers().values().toString()); } logger.debug("------- Games: " + managerFactory.gameManager().getNumberActiveGames() + " --------------------------------------------"); logger.debug(" Active Game Worker: " + managerFactory.threadExecutor().getActiveThreads(managerFactory.threadExecutor().getGameExecutor())); @@ -436,7 +421,8 @@ public class TableManagerImpl implements TableManager { logger.debug("--- Server state END ------------------------------------------"); } - private void checkTableHealthState() { + private void removeOutdatedTables() { + // TODO: need research and check - is it actual code or not, 2023-12-06 if (logger.isDebugEnabled()) { debugServerState(); } @@ -465,6 +451,13 @@ public class TableManagerImpl implements TableManager { } } logger.debug("TABLE HEALTH CHECK - END"); + } + @Override + public void checkHealth() { + //logger.info("Checking tables..."); + // TODO: add memory reports + // TODO: add broken tables check and report (without seats, without gamecontroller, without active players, too long playing, etc) + removeOutdatedTables(); } } diff --git a/Mage.Server/src/main/java/mage/server/User.java b/Mage.Server/src/main/java/mage/server/User.java index 77f909c7c9b..9a4d55d760d 100644 --- a/Mage.Server/src/main/java/mage/server/User.java +++ b/Mage.Server/src/main/java/mage/server/User.java @@ -29,12 +29,14 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class User { private static final Logger logger = Logger.getLogger(User.class); + public static final String ADMIN_NAME = "Admin"; // if you change here then change in SessionImpl too + public enum UserState { Created, // Used if user is created an not connected to the session Connected, // Used if user is correctly connected @@ -56,6 +58,7 @@ public class User { private final Map sideboarding; private final List watchedGames; private String sessionId; + private String restoreSessionId; // keep sessionId for possible restore on reconnect private String pingInfo = ""; private Date lastActivity; private UserState userState; @@ -118,6 +121,10 @@ public class User { return sessionId; } + public String getRestoreSessionId() { + return restoreSessionId; + } + public Date getChatLockedUntil() { return chatLockedUntil; } @@ -132,19 +139,10 @@ public class User { public void setSessionId(String sessionId) { this.sessionId = sessionId; - if (sessionId.isEmpty()) { - setUserState(UserState.Disconnected); - lostConnection(); - logger.trace("USER - lost connection: " + userName + " id: " + userId); + } - } else if (userState == UserState.Created) { - setUserState(UserState.Connected); - logger.trace("USER - created: " + userName + " id: " + userId); - } else { - setUserState(UserState.Connected); - reconnect(); - logger.trace("USER - reconnected: " + userName + " id: " + userId); - } + public void setRestoreSessionId(String restoreSessionId) { + this.restoreSessionId = restoreSessionId; } public void setClientVersion(String clientVersion) { @@ -178,14 +176,44 @@ public class User { updateAuthorizedUser(); } - public void lostConnection() { - // Because watched games don't get restored after reconnection call stop watching + /** + * Shared code for any user disconnection + *

+ * Full user instance remove will be done by reconnect or by server health check + * + * @param reason + */ + public void onLostConnection(DisconnectReason reason) { + // stats for bad connections only + if (reason != DisconnectReason.DisconnectedByUser + && reason != DisconnectReason.DisconnectedByUserButKeepTables) { + ServerMessagesUtil.instance.incLostConnection(); + } + + // chats restored on reconnection from a scrach by game panels - so don't keep it + managerFactory.chatManager().removeUser(userId, reason); + + // watched games don't get restored after reconnection call - so don't keep it for (Iterator iterator = watchedGames.iterator(); iterator.hasNext(); ) { UUID gameId = iterator.next(); managerFactory.gameManager().stopWatching(gameId, userId); iterator.remove(); } - ServerMessagesUtil.instance.incLostConnection(); + + // game tables + if (reason.isRemoveUserTables) { + // full disconnection + removeUserFromAllTables(reason); // it's duplicates some chat/watch code inside, but it ok + setUserState(UserState.Offline); + managerFactory.userManager().removeUser(getId()); + } else { + setUserState(UserState.Disconnected); + } + + // kill active session + String oldSessionId = sessionId; + setSessionId(""); + managerFactory.sessionManager().disconnect(oldSessionId, reason, false); } public boolean isConnected() { @@ -317,9 +345,7 @@ public class User { this.pingInfo = pingInfo; } lastActivity = new Date(); - if (userState == UserState.Disconnected) { // this can happen if user reconnects very fast after disconnect - setUserState(UserState.Connected); - } + setUserState(UserState.Connected); } public boolean isExpired(Date expired) { @@ -332,11 +358,19 @@ public class User { } - private void reconnect() { - logger.trace(userName + " started reconnect"); + /** + * Restore all active tables and another data + */ + public void onReconnect() { + // TODO: research tables restore - it's old code, so check table state, timers, actions, ?concede?, ?skips? + ServerMessagesUtil.instance.incReconnects(); + + // active tables for (Entry entry : tables.entrySet()) { ccJoinedTable(entry.getValue().getRoomId(), entry.getValue().getId(), entry.getValue().isTournament()); } + + // active tourneys for (Iterator> iterator = userTournaments.entrySet().iterator(); iterator.hasNext(); ) { Entry next = iterator.next(); Optional tournamentController = managerFactory.tournamentManager().getTournamentController(next.getValue()); @@ -347,21 +381,27 @@ public class User { iterator.remove(); // tournament has ended meanwhile } } + + // active games for (Entry entry : gameSessions.entrySet()) { ccGameStarted(entry.getValue().getGameId(), entry.getKey()); entry.getValue().init(); managerFactory.gameManager().sendPlayerString(entry.getValue().getGameId(), userId, ""); } + // active drafts for (Entry entry : draftSessions.entrySet()) { ccDraftStarted(entry.getValue().getDraftId(), entry.getKey()); entry.getValue().init(); entry.getValue().update(); } + // active constructing for (Entry entry : constructing.entrySet()) { entry.getValue().construct(0); // TODO: Check if this is correct } + + // active sideboarding for (Entry entry : sideboarding.entrySet()) { Optional controller = managerFactory.tableManager().getController(entry.getKey()); if (controller.isPresent()) { @@ -372,8 +412,6 @@ public class User { logger.debug(getName() + " reconnects during sideboarding but tableId not found: " + entry.getKey()); } } - ServerMessagesUtil.instance.incReconnects(); - logger.trace(userName + " ended reconnect"); } public void addGame(UUID playerId, GameSessionPlayer gameSession) { diff --git a/Mage.Server/src/main/java/mage/server/UserManagerImpl.java b/Mage.Server/src/main/java/mage/server/UserManagerImpl.java index 8f10aadd7d1..39c1c6af836 100644 --- a/Mage.Server/src/main/java/mage/server/UserManagerImpl.java +++ b/Mage.Server/src/main/java/mage/server/UserManagerImpl.java @@ -1,8 +1,7 @@ package mage.server; -import mage.server.User.UserState; -import mage.server.managers.UserManager; import mage.server.managers.ManagerFactory; +import mage.server.managers.UserManager; import mage.server.record.UserStats; import mage.server.record.UserStatsRepository; import mage.view.UserView; @@ -15,19 +14,19 @@ import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * manages users - if a user is disconnected and 10 minutes have passed with no - * activity the user is removed + * Server: manage active user instances (connected to the server) * - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class UserManagerImpl implements UserManager { - private static final int SERVER_TIMEOUTS_USER_INFORM_OPPONENTS_ABOUT_DISCONNECT_AFTER_SECS = 30; // send to chat info about disconnection troubles, must be more than ping timeout - private static final int SERVER_TIMEOUTS_USER_DISCONNECT_FROM_SERVER_AFTER_SECS = 3 * 60; // removes from all games and chats too (can be seen in users list with disconnected status) - private static final int SERVER_TIMEOUTS_USER_REMOVE_FROM_SERVER_AFTER_SECS = 8 * 60; // removes from users list + // timeouts on user's activity (on connection problems) + private static final int USER_CONNECTION_TIMEOUTS_CHECK_SECS = 30; // TODO: replace to 60 before merge + private static final int USER_CONNECTION_TIMEOUT_INFORM_AFTER_SECS = 30; // inform user's opponents about problem + private static final int USER_CONNECTION_TIMEOUT_SESSION_EXPIRE_AFTER_SECS = 3 * 60; // session expire - remove from all tables and chats (can't reconnect after it) + private static final int USER_CONNECTION_TIMEOUT_REMOVE_FROM_SERVER_SECS = 8 * 60; // removes from users list - private static final int SERVER_TIMEOUTS_USER_EXPIRE_CHECK_SECS = 60; - private static final int SERVER_TIMEOUTS_USERS_LIST_UPDATE_SECS = 4; // server side updates (client use own timeouts to request users list) + private static final int SERVER_USERS_LIST_UPDATE_SECS = 4; // server side updates (client use own timeouts to request users list) private static final Logger logger = Logger.getLogger(UserManagerImpl.class); @@ -49,8 +48,8 @@ public class UserManagerImpl implements UserManager { public void init() { USER_EXECUTOR = managerFactory.threadExecutor().getCallExecutor(); - expireExecutor.scheduleAtFixedRate(this::checkExpired, SERVER_TIMEOUTS_USER_EXPIRE_CHECK_SECS, SERVER_TIMEOUTS_USER_EXPIRE_CHECK_SECS, TimeUnit.SECONDS); - userListExecutor.scheduleAtFixedRate(this::updateUserInfoList, SERVER_TIMEOUTS_USERS_LIST_UPDATE_SECS, SERVER_TIMEOUTS_USERS_LIST_UPDATE_SECS, TimeUnit.SECONDS); + expireExecutor.scheduleAtFixedRate(this::checkExpired, USER_CONNECTION_TIMEOUTS_CHECK_SECS, USER_CONNECTION_TIMEOUTS_CHECK_SECS, TimeUnit.SECONDS); + userListExecutor.scheduleAtFixedRate(this::updateUserInfoList, SERVER_USERS_LIST_UPDATE_SECS, SERVER_USERS_LIST_UPDATE_SECS, TimeUnit.SECONDS); } @Override @@ -71,11 +70,16 @@ public class UserManagerImpl implements UserManager { @Override public Optional getUser(UUID userId) { - if (!users.containsKey(userId)) { - //logger.warn(String.format("User with id %s could not be found", userId), new Throwable()); // TODO: remove after session freezes fixed + if (userId == null) { return Optional.empty(); - } else { - return Optional.of(users.get(userId)); + } + + final Lock r = lock.readLock(); + r.lock(); + try { + return Optional.ofNullable(users.getOrDefault(userId, null)); + } finally { + r.unlock(); } } @@ -84,12 +88,13 @@ public class UserManagerImpl implements UserManager { final Lock r = lock.readLock(); r.lock(); try { - return users.values().stream().filter(user -> user.getName().equals(userName)) + return users.values() + .stream() + .filter(user -> user.getName().equals(userName)) .findFirst(); } finally { r.unlock(); } - } @Override @@ -108,9 +113,9 @@ public class UserManagerImpl implements UserManager { @Override public boolean connectToSession(String sessionId, UUID userId) { if (userId != null) { - User user = users.get(userId); - if (user != null) { - user.setSessionId(sessionId); + Optional user = getUser(userId); + if (user.isPresent()) { + user.get().setSessionId(sessionId); return true; } } @@ -119,45 +124,17 @@ public class UserManagerImpl implements UserManager { @Override public void disconnect(UUID userId, DisconnectReason reason) { - Optional user = getUser(userId); - if (user.isPresent()) { - user.get().setSessionId(""); - if (reason == DisconnectReason.Disconnected) { - removeUserFromAllTablesAndChat(userId, reason); - user.get().setUserState(UserState.Offline); - } + User user = getUser(userId).orElse(null); + if (user != null) { + user.onLostConnection(reason); } } @Override public boolean isAdmin(UUID userId) { - if (userId != null) { - User user = users.get(userId); - if (user != null) { - return user.getName().equals("Admin"); - } - } - return false; - } - - @Override - public void removeUserFromAllTablesAndChat(final UUID userId, final DisconnectReason reason) { - if (userId != null) { - getUser(userId).ifPresent(user - -> USER_EXECUTOR.execute( - () -> { - try { - logger.info("USER REMOVE - " + user.getName() + " (" + reason.toString() + ") userId: " + userId + " [" + user.getGameInfo() + ']'); - user.removeUserFromAllTables(reason); - managerFactory.chatManager().removeUser(user.getId(), reason); - logger.debug("USER REMOVE END - " + user.getName()); - } catch (Exception ex) { - handleException(ex); - } - } - )); - - } + return getUser(userId) + .filter(u -> u.getName().equals(User.ADMIN_NAME)) + .isPresent(); } @Override @@ -195,13 +172,13 @@ public class UserManagerImpl implements UserManager { */ private void checkExpired() { try { - Calendar calendarInform = Calendar.getInstance(); - calendarInform.add(Calendar.SECOND, -1 * SERVER_TIMEOUTS_USER_INFORM_OPPONENTS_ABOUT_DISCONNECT_AFTER_SECS); - Calendar calendarExp = Calendar.getInstance(); - calendarExp.add(Calendar.SECOND, -1 * SERVER_TIMEOUTS_USER_DISCONNECT_FROM_SERVER_AFTER_SECS); - Calendar calendarRemove = Calendar.getInstance(); - calendarRemove.add(Calendar.SECOND, -1 * SERVER_TIMEOUTS_USER_REMOVE_FROM_SERVER_AFTER_SECS); - List toRemove = new ArrayList<>(); + Calendar calInform = Calendar.getInstance(); + calInform.add(Calendar.SECOND, -1 * USER_CONNECTION_TIMEOUT_INFORM_AFTER_SECS); + Calendar calSessionExpire = Calendar.getInstance(); + calSessionExpire.add(Calendar.SECOND, -1 * USER_CONNECTION_TIMEOUT_SESSION_EXPIRE_AFTER_SECS); + Calendar calUserRemove = Calendar.getInstance(); + calUserRemove.add(Calendar.SECOND, -1 * USER_CONNECTION_TIMEOUT_REMOVE_FROM_SERVER_SECS); + List usersToRemove = new ArrayList<>(); logger.debug("Start Check Expired"); List userList = new ArrayList<>(); final Lock r = lock.readLock(); @@ -213,37 +190,60 @@ public class UserManagerImpl implements UserManager { } for (User user : userList) { try { - if (user.getUserState() != UserState.Offline - && user.isExpired(calendarInform.getTime())) { - long secsInfo = (Calendar.getInstance().getTimeInMillis() - user.getLastActivity().getTime()) / 1000; - informUserOpponents(user.getId(), user.getName() + " got connection problem for " + secsInfo + " secs"); - } + // expire logic: + // - any user actions will update user's last activity date + // - expire code schedules to check last activity every few seconds (minutes) + // - if something outdated then it will be removed from a server + // - user lifecycle: created -> connected/disconnected (bad connection, bad session) -> offline - if (user.getUserState() == UserState.Offline) { - if (user.isExpired(calendarRemove.getTime())) { - // removes from users list - toRemove.add(user); + boolean isBadConnection = user.isExpired(calInform.getTime()); + boolean isBadSession = user.isExpired(calSessionExpire.getTime()); + boolean isBadUser = user.isExpired(calUserRemove.getTime()); + + switch (user.getUserState()) { + case Created: { + // ignore + break; } - } else { - if (user.isExpired(calendarExp.getTime())) { - // set disconnected status and removes from all activities (tourney/tables/games/drafts/chats) - if (user.getUserState() == UserState.Connected) { - user.lostConnection(); - disconnect(user.getId(), DisconnectReason.BecameInactive); + + case Offline: { + // remove user from a server (users list for GUI) + if (isBadUser) { + usersToRemove.add(user); } - removeUserFromAllTablesAndChat(user.getId(), DisconnectReason.SessionExpired); - user.setUserState(UserState.Offline); + break; + } + + case Connected: + case Disconnected: { + if (isBadConnection) { + long secsInfo = (Calendar.getInstance().getTimeInMillis() - user.getLastActivity().getTime()) / 1000; + informUserOpponents(user.getId(), String.format("%s catch connection problems for %s secs (left before expire: %d secs)", + user.getName(), + secsInfo, + Math.max(0, USER_CONNECTION_TIMEOUT_SESSION_EXPIRE_AFTER_SECS - secsInfo) + )); + } + if (isBadSession) { + // full disconnect + disconnect(user.getId(), DisconnectReason.SessionExpired); + } + break; + } + + default: { + throw new IllegalArgumentException("Unknown user state: " + user.getUserState()); } } } catch (Exception ex) { handleException(ex); } } - logger.debug("Users to remove " + toRemove.size()); - final Lock w = lock.readLock(); + logger.debug("Users to remove " + usersToRemove.size()); + final Lock w = lock.writeLock(); w.lock(); try { - for (User user : toRemove) { + for (User user : usersToRemove) { users.remove(user.getId()); } } finally { @@ -255,6 +255,28 @@ public class UserManagerImpl implements UserManager { } } + /** + * Remove user instance from a server + * Warning, call it after all tables/chats and other user related data removes + * + * @param userId + */ + @Override + public void removeUser(UUID userId) { + try { + final Lock w = lock.writeLock(); + w.lock(); + try { + users.remove(userId); + } finally { + w.unlock(); + } + } catch (Exception e) { + handleException(e); + } + + } + /** * This method recreated the user list that will be sent to all clients */ @@ -322,4 +344,10 @@ public class UserManagerImpl implements UserManager { } }); } + + @Override + public void checkHealth() { + //logger.info("Checking users..."); + // TODO: add broken users check and report (too long without sessions) + } } diff --git a/Mage.Server/src/main/java/mage/server/draft/DraftSession.java b/Mage.Server/src/main/java/mage/server/draft/DraftSession.java index 352feae7c00..1403d4784b4 100644 --- a/Mage.Server/src/main/java/mage/server/draft/DraftSession.java +++ b/Mage.Server/src/main/java/mage/server/draft/DraftSession.java @@ -52,11 +52,16 @@ public class DraftSession { if (!killed) { Optional user = managerFactory.userManager().getUser(userId); if (user.isPresent()) { + int remaining; if (futureTimeout != null && !futureTimeout.isDone()) { - int remaining = (int) futureTimeout.getDelay(TimeUnit.SECONDS); - user.get().fireCallback(new ClientCallback(ClientCallbackMethod.DRAFT_INIT, draft.getId(), - new DraftClientMessage(getDraftView(), getDraftPickView(remaining)))); + // picking already runs + remaining = (int) futureTimeout.getDelay(TimeUnit.SECONDS); + } else { + // picking not started yet + remaining = draft.getPickTimeout(); } + user.get().fireCallback(new ClientCallback(ClientCallbackMethod.DRAFT_INIT, draft.getId(), + new DraftClientMessage(getDraftView(), getDraftPickView(remaining)))); return true; } } diff --git a/Mage.Server/src/main/java/mage/server/game/GameController.java b/Mage.Server/src/main/java/mage/server/game/GameController.java index 6bcfca3141a..682b82011ad 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameController.java +++ b/Mage.Server/src/main/java/mage/server/game/GameController.java @@ -5,15 +5,13 @@ import mage.abilities.Ability; import mage.abilities.common.PassAbility; import mage.cards.Card; import mage.cards.Cards; -import mage.cards.decks.Deck; -import mage.cards.decks.DeckCardLists; -import mage.cards.repository.CardInfo; -import mage.cards.repository.CardRepository; import mage.choices.Choice; import mage.constants.ManaType; import mage.constants.PlayerAction; -import mage.constants.Zone; -import mage.game.*; +import mage.game.Game; +import mage.game.GameOptions; +import mage.game.GameState; +import mage.game.Table; import mage.game.command.Plane; import mage.game.events.Listener; import mage.game.events.PlayerQueryEvent; @@ -46,7 +44,7 @@ import java.util.stream.Collectors; import java.util.zip.GZIPOutputStream; /** - * @author BetaSteward_at_googlemail.com + * @author BetaSteward_at_googlemail.com, JayDi85 */ public class GameController implements GameCallback { @@ -317,7 +315,6 @@ public class GameController implements GameCallback { } user.get().addGame(playerId, gameSession); logger.debug("Player " + player.getName() + ' ' + playerId + " has " + joinType + " gameId: " + game.getId()); - // TODO: force user update?! managerFactory.chatManager().broadcast(chatId, "", game.getPlayer(playerId).getLogName() + " has " + joinType + " the game", MessageColor.ORANGE, true, game, MessageType.GAME, null); checkStart(); } @@ -347,7 +344,7 @@ public class GameController implements GameCallback { } private void sendInfoAboutPlayersNotJoinedYetAndTryToFixIt() { - // runs every 5 secs untill all players join + // runs every 10 secs untill all players join for (Player player : game.getPlayers().values()) { if (player.canRespond() && player.isHuman()) { Optional requestedUser = getUserByPlayerId(player.getId()); @@ -358,7 +355,7 @@ public class GameController implements GameCallback { // join the game because player has not joined or was removed because of disconnect String problemPlayerFixes; user.removeConstructing(player.getId()); - managerFactory.gameManager().joinGame(game.getId(), user.getId()); + managerFactory.gameManager().joinGame(game.getId(), user.getId()); // will generate new session on empty logger.warn("Forced join of player " + player.getName() + " (" + user.getUserState() + ") to gameId: " + game.getId()); if (user.isConnected()) { // init game session, see reconnect() @@ -370,9 +367,8 @@ public class GameController implements GameCallback { session.init(); managerFactory.gameManager().sendPlayerString(session.getGameId(), user.getId(), ""); } else { - problemPlayerFixes = "leave on broken game session"; - logger.error("Can't find game session for forced join, leave it: player " + player.getName() + " in gameId: " + game.getId()); - player.leave(); + throw new IllegalStateException("Wrong code usage: session can't be null cause it created in forced joinGame already"); + //player.leave(); } } else { problemPlayerFixes = "leave on disconnected"; @@ -868,12 +864,12 @@ public class GameController implements GameCallback { } private synchronized void multiAmount(UUID playerId, final List messages, - final int min, final int max, final Map options) + final int min, final int max, final Map options) throws MageException { perform(playerId, playerId1 -> getGameSession(playerId1).getMultiAmount(messages, min, max, options)); } - private void informOthers(UUID playerId) throws MageException { + private void informOthers(UUID playerId) { StringBuilder message = new StringBuilder(); if (game.getStep() != null) { message.append(game.getTurnStepType().toString()).append(" - "); @@ -889,7 +885,7 @@ public class GameController implements GameCallback { } } - private void informOthers(List players) throws MageException { + private void informOthers(List players) { // first player is always original controller Player controller = null; if (players != null && !players.isEmpty()) { @@ -974,18 +970,19 @@ public class GameController implements GameCallback { * * @param playerId * @param command - * @throws MageException */ - private void perform(UUID playerId, Command command) throws MageException { + private void perform(UUID playerId, Command command) { perform(playerId, command, true); } - private void perform(UUID playerId, Command command, boolean informOthers) throws MageException { + private void perform(UUID playerId, Command command, boolean informOthers) { if (game.getPlayer(playerId).isGameUnderControl()) { // is the player controlling it's own turn if (gameSessions.containsKey(playerId)) { setupTimeout(playerId); command.execute(playerId); } + // TODO: if watcher disconnects then game freezes with active timer, must be fix for such use case + // same for another player (can be fixed by super-duper connection) if (informOthers) { informOthers(playerId); } @@ -997,6 +994,8 @@ public class GameController implements GameCallback { command.execute(uuid); } } + // TODO: if watcher disconnects then game freeze with active timer, must be fix for such use case + // same for another player (can be fixed by super-duper connection) if (informOthers) { informOthers(players); } diff --git a/Mage.Server/src/main/java/mage/server/game/GamesRoomImpl.java b/Mage.Server/src/main/java/mage/server/game/GamesRoomImpl.java index e4e72d9fe05..f28583e2637 100644 --- a/Mage.Server/src/main/java/mage/server/game/GamesRoomImpl.java +++ b/Mage.Server/src/main/java/mage/server/game/GamesRoomImpl.java @@ -31,6 +31,8 @@ public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable { private static final Logger LOGGER = Logger.getLogger(GamesRoomImpl.class); + private static final int MAX_FINISHED_TABLES = 50; + private static final ScheduledExecutorService UPDATE_EXECUTOR = Executors.newSingleThreadScheduledExecutor(); private static List tableView = new ArrayList<>(); private static List matchView = new ArrayList<>(); @@ -65,7 +67,7 @@ public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable { for (Table table : allTables) { if (table.getState() != TableState.FINISHED) { tableList.add(new TableView(table)); - } else if (matchList.size() < 50) { + } else if (matchList.size() < MAX_FINISHED_TABLES) { matchList.add(new MatchView(table)); } else { // more since 50 matches finished since this match so removeUserFromAllTablesAndChat it @@ -79,7 +81,8 @@ public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable { matchView = matchList; List users = new ArrayList<>(); for (User user : managerFactory.userManager().getUsers()) { - if (user.getUserState() != User.UserState.Offline && !user.getName().equals("Admin")) { + if (user.getUserState() != User.UserState.Offline + && !user.getName().equals(User.ADMIN_NAME)) { try { users.add(new UsersView(user.getUserData().getFlagName(), user.getName(), user.getMatchHistory(), user.getMatchQuitRatio(), user.getTourneyHistory(), @@ -244,12 +247,4 @@ class TableListSorter implements Comparator { } return 0; } -} - -class UserNameSorter implements Comparator { - - @Override - public int compare(UsersView one, UsersView two) { - return one.getUserName().compareToIgnoreCase(two.getUserName()); - } -} +} \ No newline at end of file diff --git a/Mage.Server/src/main/java/mage/server/managers/ChatManager.java b/Mage.Server/src/main/java/mage/server/managers/ChatManager.java index 504911f7b28..7319561890d 100644 --- a/Mage.Server/src/main/java/mage/server/managers/ChatManager.java +++ b/Mage.Server/src/main/java/mage/server/managers/ChatManager.java @@ -10,27 +10,24 @@ import java.util.List; import java.util.UUID; public interface ChatManager { + UUID createChatSession(String info); void joinChat(UUID chatId, UUID userId); - void clearUserMessageStorage(); - void leaveChat(UUID chatId, UUID userId); void destroyChatSession(UUID chatId); void broadcast(UUID chatId, String userName, String message, ChatMessage.MessageColor color, boolean withTime, Game game, ChatMessage.MessageType messageType, ChatMessage.SoundToPlay soundToPlay); - void broadcast(UUID userId, String message, ChatMessage.MessageColor color) throws UserNotFoundException; - void sendReconnectMessage(UUID userId); - void sendLostConnectionMessage(UUID userId, DisconnectReason reason); - void sendMessageToUserChats(UUID userId, String message); void removeUser(UUID userId, DisconnectReason reason); List getChatSessions(); + + void checkHealth(); } diff --git a/Mage.Server/src/main/java/mage/server/managers/SessionManager.java b/Mage.Server/src/main/java/mage/server/managers/SessionManager.java index d63f96be770..c5d1559b068 100644 --- a/Mage.Server/src/main/java/mage/server/managers/SessionManager.java +++ b/Mage.Server/src/main/java/mage/server/managers/SessionManager.java @@ -18,19 +18,22 @@ public interface SessionManager { boolean registerUser(String sessionId, String userName, String password, String email) throws MageException; - boolean connectUser(String sessionId, String userName, String password, String userIdStr) throws MageException; + boolean connectUser(String sessionId, String restoreSessionId, String userName, String password, String userInfo, boolean detailsMode) throws MageException; boolean connectAdmin(String sessionId); boolean setUserData(String userName, String sessionId, UserData userData, String clientVersion, String userIdStr) throws MageException; - void disconnect(String sessionId, DisconnectReason reason); + /** + * Disconnect from a session side, e.g. on connection error + * + * @param sessionId + * @param reason + * @param checkUserDisconnection check and remove user's tables too, use it by default + */ + void disconnect(String sessionId, DisconnectReason reason, boolean checkUserDisconnection); - void disconnect(String sessionId, DisconnectReason reason, Session directSession); - - void disconnectUser(String sessionId, String userSessionId); - - void endUserSession(String sessionId, String userSessionId); + void disconnectAnother(String sessionId, String userSessionId); boolean checkAdminAccess(String sessionId); @@ -41,4 +44,6 @@ public interface SessionManager { boolean extendUserSession(String sessionId, String pingInfo); void sendErrorMessageToClient(String sessionId, String message); + + void checkHealth(); } diff --git a/Mage.Server/src/main/java/mage/server/managers/TableManager.java b/Mage.Server/src/main/java/mage/server/managers/TableManager.java index 7ba5d81c757..c1840a2e3a7 100644 --- a/Mage.Server/src/main/java/mage/server/managers/TableManager.java +++ b/Mage.Server/src/main/java/mage/server/managers/TableManager.java @@ -83,4 +83,6 @@ public interface TableManager { void removeTable(UUID tableId); void debugServerState(); + + void checkHealth(); } diff --git a/Mage.Server/src/main/java/mage/server/managers/ThreadExecutor.java b/Mage.Server/src/main/java/mage/server/managers/ThreadExecutor.java index 46f59b53435..6683ec8ecf9 100644 --- a/Mage.Server/src/main/java/mage/server/managers/ThreadExecutor.java +++ b/Mage.Server/src/main/java/mage/server/managers/ThreadExecutor.java @@ -1,7 +1,9 @@ package mage.server.managers; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; public interface ThreadExecutor { int getActiveThreads(ExecutorService executerService); @@ -13,4 +15,6 @@ public interface ThreadExecutor { ScheduledExecutorService getTimeoutExecutor(); ScheduledExecutorService getTimeoutIdleExecutor(); + + ScheduledExecutorService getServerHealthExecutor(); } diff --git a/Mage.Server/src/main/java/mage/server/managers/UserManager.java b/Mage.Server/src/main/java/mage/server/managers/UserManager.java index 626609ee251..eeee2cb431c 100644 --- a/Mage.Server/src/main/java/mage/server/managers/UserManager.java +++ b/Mage.Server/src/main/java/mage/server/managers/UserManager.java @@ -11,6 +11,7 @@ import java.util.Optional; import java.util.UUID; public interface UserManager { + Optional createUser(String userName, String host, AuthorizedUser authorizedUser); Optional getUser(UUID userId); @@ -25,12 +26,12 @@ public interface UserManager { boolean isAdmin(UUID userId); - void removeUserFromAllTablesAndChat(UUID userId, DisconnectReason reason); - void informUserOpponents(UUID userId, String message); boolean extendUserSession(UUID userId, String pingInfo); + void removeUser(UUID userId); + List getUserInfoList(); void handleException(Exception ex); @@ -38,4 +39,6 @@ public interface UserManager { String getUserHistory(String userName); void updateUserHistory(); + + void checkHealth(); } diff --git a/Mage.Server/src/main/java/mage/server/util/ServerMessagesUtil.java b/Mage.Server/src/main/java/mage/server/util/ServerMessagesUtil.java index 36e4a4a1d0a..6f19ba86107 100644 --- a/Mage.Server/src/main/java/mage/server/util/ServerMessagesUtil.java +++ b/Mage.Server/src/main/java/mage/server/util/ServerMessagesUtil.java @@ -38,7 +38,7 @@ public enum ServerMessagesUtil { private static final AtomicInteger gamesEnded = new AtomicInteger(0); private static final AtomicInteger tournamentsStarted = new AtomicInteger(0); private static final AtomicInteger tournamentsEnded = new AtomicInteger(0); - private static final AtomicInteger lostConnection = new AtomicInteger(0); + private static final AtomicInteger lostConnection = new AtomicInteger(0); // bad connections only private static final AtomicInteger reconnects = new AtomicInteger(0); ServerMessagesUtil() { diff --git a/Mage.Server/src/main/java/mage/server/util/ThreadExecutorImpl.java b/Mage.Server/src/main/java/mage/server/util/ThreadExecutorImpl.java index 8f9899b10c0..bf83aedba10 100644 --- a/Mage.Server/src/main/java/mage/server/util/ThreadExecutorImpl.java +++ b/Mage.Server/src/main/java/mage/server/util/ThreadExecutorImpl.java @@ -2,7 +2,7 @@ package mage.server.util; import mage.server.managers.ConfigSettings; import mage.server.managers.ThreadExecutor; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; import org.apache.log4j.Logger; import java.util.concurrent.*; @@ -18,6 +18,7 @@ public class ThreadExecutorImpl implements ThreadExecutor { private final ExecutorService gameExecutor; // game threads to run long tasks, one per game (example: run game and wait user's feedback) private final ScheduledExecutorService timeoutExecutor; private final ScheduledExecutorService timeoutIdleExecutor; + private final ScheduledExecutorService serverHealthExecutor; /** * noxx: what the settings below do is setting the ability to keep OS @@ -33,23 +34,27 @@ public class ThreadExecutorImpl implements ThreadExecutor { public ThreadExecutorImpl(ConfigSettings config) { //callExecutor = Executors.newCachedThreadPool(); callExecutor = new CachedThreadPoolWithException(); - //gameExecutor = Executors.newFixedThreadPool(config.getMaxGameThreads()); - gameExecutor = new FixedThreadPoolWithException(config.getMaxGameThreads()); - timeoutExecutor = Executors.newScheduledThreadPool(4); - timeoutIdleExecutor = Executors.newScheduledThreadPool(4); - ((ThreadPoolExecutor) callExecutor).setKeepAliveTime(60, TimeUnit.SECONDS); ((ThreadPoolExecutor) callExecutor).allowCoreThreadTimeOut(true); ((ThreadPoolExecutor) callExecutor).setThreadFactory(new XMageThreadFactory("CALL")); + + //gameExecutor = Executors.newFixedThreadPool(config.getMaxGameThreads()); + gameExecutor = new FixedThreadPoolWithException(config.getMaxGameThreads()); ((ThreadPoolExecutor) gameExecutor).setKeepAliveTime(60, TimeUnit.SECONDS); ((ThreadPoolExecutor) gameExecutor).allowCoreThreadTimeOut(true); ((ThreadPoolExecutor) gameExecutor).setThreadFactory(new XMageThreadFactory("GAME")); + + timeoutExecutor = Executors.newScheduledThreadPool(4); ((ThreadPoolExecutor) timeoutExecutor).setKeepAliveTime(60, TimeUnit.SECONDS); ((ThreadPoolExecutor) timeoutExecutor).allowCoreThreadTimeOut(true); ((ThreadPoolExecutor) timeoutExecutor).setThreadFactory(new XMageThreadFactory("TIMEOUT")); + + timeoutIdleExecutor = Executors.newScheduledThreadPool(4); ((ThreadPoolExecutor) timeoutIdleExecutor).setKeepAliveTime(60, TimeUnit.SECONDS); ((ThreadPoolExecutor) timeoutIdleExecutor).allowCoreThreadTimeOut(true); ((ThreadPoolExecutor) timeoutIdleExecutor).setThreadFactory(new XMageThreadFactory("TIMEOUT_IDLE")); + + serverHealthExecutor = Executors.newSingleThreadScheduledExecutor(new XMageThreadFactory("HEALTH")); } static class CachedThreadPoolWithException extends ThreadPoolExecutor { @@ -119,6 +124,10 @@ public class ThreadExecutorImpl implements ThreadExecutor { return timeoutIdleExecutor; } + @Override + public ScheduledExecutorService getServerHealthExecutor() { + return serverHealthExecutor; + } } class XMageThreadFactory implements ThreadFactory { diff --git a/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java b/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java index 0a5cdb2c029..f37ff6f5aac 100644 --- a/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java +++ b/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java @@ -41,7 +41,12 @@ public class LoadCallbackClient implements CallbackClient { } @Override - public void processCallback(ClientCallback callback) { + public void onNewConnection() { + // nothing to do, only one time connection for LoadClient + } + + @Override + public void onCallback(ClientCallback callback) { callback.decompressData(); controlCount = 0; diff --git a/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java b/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java index d908923f4e6..a2b783f3c12 100644 --- a/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java @@ -47,7 +47,7 @@ public class LoadTest { private static final Boolean TEST_SHOW_GAME_LOGS_AS_HTML = false; // html is original format with full data, but can be too bloated private static final String TEST_AI_GAME_MODE = "Freeform Commander Free For All"; private static final String TEST_AI_DECK_TYPE = "Variant Magic - Freeform Commander"; - private static final String TEST_AI_RANDOM_DECK_SETS = "NEO"; // set for random generated decks (empty for all sets usage) + private static final String TEST_AI_RANDOM_DECK_SETS = "NEO"; // set for random generated decks (empty for all sets usage, PELP for lands only - communication test) 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) @@ -223,8 +223,8 @@ public class LoadTest { UUID tableId = game.getTableId(); // deck load - DeckCardLists deckList1 = loadGameDeck(1, deckColors, false, deckAllowedSets); - DeckCardLists deckList2 = loadGameDeck(2, deckColors, false, deckAllowedSets); + DeckCardLists deckList1 = loadGameDeck(1, deckColors, deckAllowedSets.equals("PELP"), deckAllowedSets); + DeckCardLists deckList2 = loadGameDeck(2, deckColors, deckAllowedSets.equals("PELP"), deckAllowedSets); // join AI Assert.assertTrue(monitor.session.joinTable(monitor.roomID, tableId, "ai_1", PlayerType.COMPUTER_MAD, 5, deckList1, "")); @@ -607,7 +607,7 @@ public class LoadTest { } public void disconnect() { - this.session.connectStop(false); + this.session.connectStop(false, false); } public void concede() { @@ -819,7 +819,7 @@ public class LoadTest { List data = Arrays.asList( "TOTAL/AVG", //"index", String.valueOf(this.size()), //"name", - "", // "random sid", + "total, secs: " + String.format("%.3f", (float) this.getTotalDurationMs() / 1000), // "random sid", String.valueOf(this.getAvgTurn()), // turn String.valueOf(this.getAvgLife1()), // player 1 String.valueOf(this.getAvgLife2()), // player 2 @@ -841,6 +841,10 @@ public class LoadTest { return this.values().stream().mapToInt(LoadTestGameResult::getLife2).sum() / this.size(); } + private int getTotalDurationMs() { + return this.values().stream().mapToInt(LoadTestGameResult::getDurationMs).sum(); + } + private int getAvgDurationMs() { return this.values().stream().mapToInt(LoadTestGameResult::getDurationMs).sum() / this.size(); } diff --git a/Mage.Tests/src/test/java/org/mage/test/load/SimpleMageClient.java b/Mage.Tests/src/test/java/org/mage/test/load/SimpleMageClient.java index 869195d2bd0..578aa18358c 100644 --- a/Mage.Tests/src/test/java/org/mage/test/load/SimpleMageClient.java +++ b/Mage.Tests/src/test/java/org/mage/test/load/SimpleMageClient.java @@ -54,9 +54,14 @@ public class SimpleMageClient implements MageClient { } @Override - public void processCallback(ClientCallback callback) { + public void onNewConnection() { + callbackClient.onNewConnection(); + } + + @Override + public void onCallback(ClientCallback callback) { try { - callbackClient.processCallback(callback); + callbackClient.onCallback(callback); } catch (Throwable e) { log.error(e.getMessage(), e); } diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/performance/SerializationTest.java b/Mage.Tests/src/test/java/org/mage/test/serverside/performance/SerializationTest.java index cb03549240f..b762e7d609b 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/performance/SerializationTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/performance/SerializationTest.java @@ -18,7 +18,7 @@ import mage.game.permanent.PermanentImpl; import mage.remote.traffic.ZippedObjectImpl; import mage.util.CardUtil; import mage.utils.CompressUtil; -import mage.utils.ThreadUtils; +import mage.util.ThreadUtils; import mage.view.GameView; import org.jboss.remoting.serialization.impl.java.ClearableObjectOutputStream; import org.jboss.serial.io.JBossObjectInputStream; diff --git a/Mage/src/main/java/mage/constants/PlayerAction.java b/Mage/src/main/java/mage/constants/PlayerAction.java index fcb0371bce7..87591b4cb25 100644 --- a/Mage/src/main/java/mage/constants/PlayerAction.java +++ b/Mage/src/main/java/mage/constants/PlayerAction.java @@ -45,13 +45,17 @@ public enum PlayerAction { REQUEST_AUTO_ANSWER_TEXT_NO, REQUEST_AUTO_ANSWER_RESET_ALL, CLIENT_DOWNLOAD_SYMBOLS, - CLIENT_DISCONNECT, CLIENT_QUIT_TOURNAMENT, CLIENT_QUIT_DRAFT_TOURNAMENT, CLIENT_CONCEDE_GAME, CLIENT_CONCEDE_MATCH, CLIENT_STOP_WATCHING, - CLIENT_EXIT, + + CLIENT_DISCONNECT_FULL, // send disconnect to server and exit (concede) + CLIENT_DISCONNECT_KEEP_GAMES, // close app only (can re-connect again) + CLIENT_EXIT_FULL, + CLIENT_EXIT_KEEP_GAMES, + CLIENT_REMOVE_TABLE, CLIENT_DOWNLOAD_CARD_IMAGES, CLIENT_RECONNECT, diff --git a/Mage/src/main/java/mage/game/draft/Draft.java b/Mage/src/main/java/mage/game/draft/Draft.java index 11be28528b9..7fa522af59d 100644 --- a/Mage/src/main/java/mage/game/draft/Draft.java +++ b/Mage/src/main/java/mage/game/draft/Draft.java @@ -45,6 +45,8 @@ public interface Draft extends MageItem, Serializable { void addPlayerQueryEventListener(Listener listener); void firePickCardEvent(UUID playerId); + int getPickTimeout(); + boolean isAbort(); void setAbort(boolean abort); diff --git a/Mage/src/main/java/mage/game/draft/DraftImpl.java b/Mage/src/main/java/mage/game/draft/DraftImpl.java index 9d55f48b18f..c36a3e4c5e4 100644 --- a/Mage/src/main/java/mage/game/draft/DraftImpl.java +++ b/Mage/src/main/java/mage/game/draft/DraftImpl.java @@ -313,6 +313,11 @@ public abstract class DraftImpl implements Draft { @Override public void firePickCardEvent(UUID playerId) { DraftPlayer player = players.get(playerId); + playerQueryEventSource.pickCard(playerId, "Pick card", player.getBooster(), getPickTimeout()); + } + + @Override + public int getPickTimeout() { int cardNum = Math.min(15, this.cardNum); int time = timing.getPickTimeout(cardNum); // if the pack is re-sent to a player because they haven't been able to successfully load it, the pick time is reduced appropriately because of the elapsed time @@ -320,7 +325,7 @@ public abstract class DraftImpl implements Draft { if (time > 0) { time = Math.max(1, time - boosterLoadingCounter * BOOSTER_LOADING_INTERVAL); } - playerQueryEventSource.pickCard(playerId, "Pick card", player.getBooster(), time); + return time; } @Override diff --git a/Mage/src/main/java/mage/game/match/MatchImpl.java b/Mage/src/main/java/mage/game/match/MatchImpl.java index db9e51bcddb..6deb8f1d1e1 100644 --- a/Mage/src/main/java/mage/game/match/MatchImpl.java +++ b/Mage/src/main/java/mage/game/match/MatchImpl.java @@ -11,8 +11,10 @@ import mage.game.events.TableEventSource; import mage.game.result.ResultProtos.MatchProto; import mage.game.result.ResultProtos.MatchQuitStatus; import mage.players.Player; +import mage.util.CardUtil; import mage.util.DateFormat; import mage.util.RandomUtil; +import mage.util.ThreadUtils; import org.apache.log4j.Logger; import java.util.*; @@ -318,6 +320,8 @@ public abstract class MatchImpl implements Match { @Override public void sideboard() { + ThreadUtils.ensureRunInGameThread(); + for (MatchPlayer player : this.players) { if (!player.hasQuit()) { if (player.getDeck() != null) { diff --git a/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java b/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java index c44c218f02e..f6bd6f6875c 100644 --- a/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java +++ b/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java @@ -8,6 +8,8 @@ import mage.players.Player; import mage.players.PlayerType; import mage.util.DeckBuildUtils; import mage.util.TournamentUtil; +import org.apache.log4j.Logger; + import java.util.Set; /** @@ -15,13 +17,15 @@ import java.util.Set; */ public class TournamentPlayer { + private static final Logger logger = Logger.getLogger(TournamentPlayer.class); + protected int points; protected PlayerType playerType; protected TournamentPlayerState state; protected String stateInfo; protected String disconnectInfo; protected Player player; - protected Deck deck; + protected Deck deck = null; protected String results; protected boolean eliminated = false; protected boolean quit = false; @@ -93,6 +97,9 @@ public class TournamentPlayer { boolean validDeck = (getDeck().getDeckCompleteHashCode() == deck.getDeckCompleteHashCode()); if (!validDeck) { // Clear the deck so the player cheating looses the game + // TODO: inform other players about cheating?! + logger.error("Found cheating player " + getPlayer().getName() + + " with changed deck, main " + deck.getCards().size() + ", side " + deck.getSideboard().size()); deck.getCards().clear(); deck.getSideboard().clear(); } @@ -100,11 +107,11 @@ public class TournamentPlayer { return validDeck; } + /** + * If user fails to submit deck on time, submit deck as is if meets minimum size, + * else add basic lands per suggested land counts + */ public Deck generateDeck(int minDeckSize) { - /* - If user fails to submit deck on time, submit deck as is if meets minimum size, - else add basic lands per suggested land counts - */ if (deck.getMaindeckCards().size() < minDeckSize) { int[] lands = DeckBuildUtils.landCountSuggestion(minDeckSize, deck.getMaindeckCards()); Set landSets = TournamentUtil.getLandSetCodeForDeckSets(deck.getExpansionSetCodes()); diff --git a/Mage.Common/src/main/java/mage/utils/ThreadUtils.java b/Mage/src/main/java/mage/util/ThreadUtils.java similarity index 51% rename from Mage.Common/src/main/java/mage/utils/ThreadUtils.java rename to Mage/src/main/java/mage/util/ThreadUtils.java index 24241ab8ef1..de27d09b27f 100644 --- a/Mage.Common/src/main/java/mage/utils/ThreadUtils.java +++ b/Mage/src/main/java/mage/util/ThreadUtils.java @@ -1,7 +1,8 @@ -package mage.utils; +package mage.util; import com.google.common.base.Throwables; +import javax.swing.*; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -9,7 +10,7 @@ import java.util.concurrent.Future; /** * Util method to work with threads. * - * @author ayrat + * @author ayrat, JayDi85 */ public final class ThreadUtils { @@ -31,7 +32,6 @@ public final class ThreadUtils { /** * Find real exception object after thread task completed. Can be used in afterExecute - * */ public static Throwable findRunnableException(Runnable r, Throwable t) { // executer.submit - return exception in result @@ -53,4 +53,28 @@ public final class ThreadUtils { public static Throwable findRootException(Throwable t) { return Throwables.getRootCause(t); } + + public static void ensureRunInGameThread() { + String name = Thread.currentThread().getName(); + if (!name.startsWith("GAME")) { + // how-to fix: use signal logic to inform a game about new command to execute instead direct execute (see example with WantConcede) + // reason: user responses/commands are received by network/call thread, but must be processed by game thread + throw new IllegalArgumentException("Wrong code usage: game related code must run in GAME thread, but it used in " + name, new Throwable()); + } + } + + public static void ensureRunInCallThread() { + String name = Thread.currentThread().getName(); + if (!name.startsWith("CALL")) { + // how-to fix: something wrong in your code logic + throw new IllegalArgumentException("Wrong code usage: client commands code must run in CALL threads, but used in " + name, new Throwable()); + } + } + + public static void ensureRunInGUISwingThread() { + if (!SwingUtilities.isEventDispatchThread()) { + // hot-to fix: run GUI changeable code by SwingUtilities.invokeLater(() -> {xxx}) + throw new IllegalArgumentException("Wrong code usage: GUI related code must run in SWING thread by SwingUtilities.invokeLater", new Throwable()); + } + } }