From 742c76a8ac4919371f889b3c9087fd2f48d722f7 Mon Sep 17 00:00:00 2001 From: asvitkine Date: Fri, 25 Mar 2022 17:05:57 -0400 Subject: [PATCH] Keep player lists and select save if a game ends due to a disconnect. (#10142) With this change, if a networked game ends because a joined player disconnects, the game lobby dialog that will be re-opened for the server is now improved in these ways: - The save game that was produced on disconnect will now be selected. - The countries / players set up from the ended game will be kept. This includes which players have selected which countries and the AI settings for the host. Countries that were played by the disconnected player will be cleared and be opened to be chosen. --- .../startup/launcher/ServerLauncher.java | 15 +++- .../framework/startup/mc/ServerModel.java | 78 ++++++++++++------- .../main/game/selector/GameSelectorModel.java | 16 +++- .../strategy/triplea/ui/ActionButtons.java | 78 ++++++++++++++----- 4 files changed, 134 insertions(+), 53 deletions(-) diff --git a/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/launcher/ServerLauncher.java b/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/launcher/ServerLauncher.java index 7ab8f2926bb..0f3666eb451 100644 --- a/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/launcher/ServerLauncher.java +++ b/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/launcher/ServerLauncher.java @@ -255,10 +255,10 @@ public void addObserver( * example, a disconnected participant will cause the game to be stopped, while a disconnected * observer will have no effect. */ - public void connectionLost(final INode node) { + public void connectionLost(final INode droppedNode) { if (isLaunching) { // this is expected, we told the observer he couldn't join, so now we lose the connection - if (observersThatTriedToJoinDuringStartup.remove(node)) { + if (observersThatTriedToJoinDuringStartup.remove(droppedNode)) { return; } // a player has dropped out, abort @@ -268,9 +268,15 @@ public void connectionLost(final INode node) { } // if we lose a connection to a player, shut down the game (after saving) and go back to the // main screen - if (serverGame.getPlayerManager().isPlaying(node)) { + if (serverGame.getPlayerManager().isPlaying(droppedNode)) { if (serverGame.isGameSequenceRunning()) { - saveAndEndGame(node); + saveAndEndGame(droppedNode); + // Release any countries played by the player that dropped. The other seating assignments + // are preserved for the game lobby that will be shown. + for (final String countryName : serverGame.getPlayerManager().getPlayedBy(droppedNode)) { + serverModel.releasePlayer(countryName); + } + serverModel.persistPlayersToNodesMapping(); } else { stopGame(); } @@ -294,6 +300,7 @@ private void saveAndEndGame(final INode node) { final Path f = launchAction.getAutoSaveFile(); try { serverGame.saveGame(f); + gameSelectorModel.setSaveGameFileToLoad(f); } catch (final Exception e) { log.error("Failed to save game: " + f.toAbsolutePath(), e); } diff --git a/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/mc/ServerModel.java b/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/mc/ServerModel.java index 53591f37880..bcafff9b77f 100644 --- a/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/mc/ServerModel.java +++ b/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/mc/ServerModel.java @@ -61,6 +61,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; import javax.annotation.Nullable; import javax.swing.JFrame; import javax.swing.JOptionPane; @@ -99,6 +100,7 @@ public class ServerModel extends Observable implements IConnectionChangeListener private Messengers messengers; private GameData data; private Map playersToNodeListing = new HashMap<>(); + private boolean playersToNodesMappingPersisted = false; private Map playersEnabledListing = new HashMap<>(); private Collection playersAllowedToBeDisabled = new HashSet<>(); private Map> playerNamesAndAlliancesInTurnOrder = @@ -313,36 +315,19 @@ public synchronized void setLocalPlayerType(final String player, final PlayerTyp localPlayerTypes.put(player, type); } + /** + * Persists the players mappings to re-use upon a game data change. Used to persist the previous + * game's player setting for the game restart if a connection is lost. + */ + public void persistPlayersToNodesMapping() { + this.playersToNodesMappingPersisted = true; + } + private void gameDataChanged() { synchronized (this) { data = gameSelectorModel.getGameData(); if (data != null) { - playersToNodeListing = new HashMap<>(); - playersEnabledListing = new HashMap<>(); - playersAllowedToBeDisabled = - new HashSet<>(data.getPlayerList().getPlayersThatMayBeDisabled()); - playerNamesAndAlliancesInTurnOrder = new LinkedHashMap<>(); - for (final GamePlayer player : data.getPlayerList().getPlayers()) { - final String name = player.getName(); - if (HeadlessGameServer.headless()) { - if (player.getIsDisabled()) { - playersToNodeListing.put(name, messengers.getLocalNode().getName()); - localPlayerTypes.put(name, PlayerTypes.WEAK_AI); - } else { - // we generally do not want a headless host bot to be doing any AI turns, since that - // is taxing on the system - playersToNodeListing.put(name, null); - } - } else { - Optional.ofNullable(messengers) - .ifPresent( - messenger -> - playersToNodeListing.put(name, messenger.getLocalNode().getName())); - } - playerNamesAndAlliancesInTurnOrder.put( - name, data.getAllianceTracker().getAlliancesPlayerIsIn(player)); - playersEnabledListing.put(name, !player.getIsDisabled()); - } + updatePlayersOnGameDataChanged(data); } objectStreamFactory.setData(data); localPlayerTypes.clear(); @@ -351,6 +336,47 @@ private void gameDataChanged() { remoteModelListener.playerListChanged(); } + private void updatePlayersOnGameDataChanged(final GameData data) { + // If specified, keep the previous player data. + if (playersToNodesMappingPersisted) { + playersToNodesMappingPersisted = false; + final Set dataPlayers = + data.getPlayerList().stream().map(GamePlayer::getName).collect(Collectors.toSet()); + // Sanity check that the list of countries matches. + if (dataPlayers.equals(playersToNodeListing.keySet())) { + // Don't regenerate the mappings, persist existing ones. + return; + } + throw new IllegalStateException("Expected countries to match when persisting seatings"); + } + + // Reset setting based on game data. + playersToNodeListing = new HashMap<>(); + playersEnabledListing = new HashMap<>(); + playersAllowedToBeDisabled = new HashSet<>(data.getPlayerList().getPlayersThatMayBeDisabled()); + playerNamesAndAlliancesInTurnOrder = new LinkedHashMap<>(); + for (final GamePlayer player : data.getPlayerList().getPlayers()) { + final String name = player.getName(); + if (HeadlessGameServer.headless()) { + if (player.getIsDisabled()) { + playersToNodeListing.put(name, messengers.getLocalNode().getName()); + localPlayerTypes.put(name, PlayerTypes.WEAK_AI); + } else { + // we generally do not want a headless host bot to be doing any AI turns, since that + // is taxing on the system + playersToNodeListing.put(name, null); + } + } else { + Optional.ofNullable(messengers) + .ifPresent( + messenger -> playersToNodeListing.put(name, messenger.getLocalNode().getName())); + } + playerNamesAndAlliancesInTurnOrder.put( + name, data.getAllianceTracker().getAlliancesPlayerIsIn(player)); + playersEnabledListing.put(name, !player.getIsDisabled()); + } + } + private Optional getServerProps() { if (System.getProperty(TRIPLEA_SERVER, "false").equals("true") && GameState.notStarted()) { GameState.setStarted(); diff --git a/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/ui/panels/main/game/selector/GameSelectorModel.java b/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/ui/panels/main/game/selector/GameSelectorModel.java index bb9bfa347d4..61977ceb7f7 100644 --- a/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/ui/panels/main/game/selector/GameSelectorModel.java +++ b/game-app/game-core/src/main/java/games/strategy/engine/framework/startup/ui/panels/main/game/selector/GameSelectorModel.java @@ -44,6 +44,7 @@ public class GameSelectorModel extends Observable implements GameSelector { // just for host bots, so we can get the actions for loading/saving games on the bots from this // model @Setter @Getter private ClientModel clientModelForHostBots = null; + private Optional saveGameToLoad = Optional.empty(); public GameSelectorModel() { this.gameParser = GameParser::parse; @@ -173,13 +174,24 @@ public void onGameEnded() { ThreadRunner.runInNewThread(this::loadDefaultGameSameThread); } + /** Sets the path of a save file that should be loaded. */ + public void setSaveGameFileToLoad(final Path filePath) { + saveGameToLoad = Optional.of(filePath.toAbsolutePath().toString()); + } + /** * Runs the load default game logic in same thread. Default game is the one that we loaded on * startup. */ public void loadDefaultGameSameThread() { - ClientSetting.defaultGameUri - .getValue() + final Optional gameUri; + if (saveGameToLoad.isPresent()) { + gameUri = saveGameToLoad; + saveGameToLoad = Optional.empty(); + } else { + gameUri = ClientSetting.defaultGameUri.getValue(); + } + gameUri .filter(Predicate.not(String::isBlank)) .filter(GameSelectorModel::gameUriExistsOnFileSystem) .map(Path::of) diff --git a/game-app/game-core/src/main/java/games/strategy/triplea/ui/ActionButtons.java b/game-app/game-core/src/main/java/games/strategy/triplea/ui/ActionButtons.java index 3da65017ca9..e5ba52bffda 100644 --- a/game-app/game-core/src/main/java/games/strategy/triplea/ui/ActionButtons.java +++ b/game-app/game-core/src/main/java/games/strategy/triplea/ui/ActionButtons.java @@ -46,22 +46,23 @@ public class ActionButtons extends JPanel { private static final long serialVersionUID = 2175685892863042399L; private final CardLayout layout = new CardLayout(); - @Getter private BattlePanel battlePanel; + @Getter private @Nullable BattlePanel battlePanel; - private MovePanel movePanel; + private @Nullable MovePanel movePanel; - private PurchasePanel purchasePanel; - private RepairPanel repairPanel; + private @Nullable PurchasePanel purchasePanel; + private @Nullable RepairPanel repairPanel; @Getter private PlacePanel placePanel; - private TechPanel techPanel; - private EndTurnPanel endTurnPanel; - private MoveForumPosterPanel moveForumPosterPanel; + private @Nullable TechPanel techPanel; + private @Nullable EndTurnPanel endTurnPanel; + private @Nullable MoveForumPosterPanel moveForumPosterPanel; + private @Nullable PoliticsPanel politicsPanel; + private @Nullable UserActionPanel userActionPanel; + private @Nullable PickTerritoryAndUnitsPanel pickTerritoryAndUnitsPanel; + private @Nullable ActionPanel actionPanel; - private PoliticsPanel politicsPanel; - private UserActionPanel userActionPanel; - private PickTerritoryAndUnitsPanel pickTerritoryAndUnitsPanel; public ActionButtons( final GameData data, @@ -128,11 +129,13 @@ public ActionButtons( } void changeToMove(final GamePlayer gamePlayer, final boolean nonCombat, final String stepName) { - movePanel.setNonCombat(nonCombat); - final boolean airBorne = stepName.endsWith("AirborneCombatMove"); - final String displayText = (airBorne ? " Airborne" : (nonCombat ? " Non" : "")); - movePanel.setDisplayText(displayText + " Combat Move"); - movePanel.setMoveType(airBorne ? MoveType.SPECIAL : MoveType.DEFAULT); + if (movePanel != null) { + movePanel.setNonCombat(nonCombat); + final boolean airBorne = stepName.endsWith("AirborneCombatMove"); + final String displayText = (airBorne ? " Airborne" : (nonCombat ? " Non" : "")); + movePanel.setDisplayText(displayText + " Combat Move"); + movePanel.setMoveType(airBorne ? MoveType.SPECIAL : MoveType.DEFAULT); + } changeTo(gamePlayer, movePanel); } @@ -150,7 +153,9 @@ public void changeToPlace(final GamePlayer gamePlayer) { public void changeToBattle( final GamePlayer gamePlayer, final Map> battles) { - battlePanel.setBattlesAndBombing(battles); + if (battlePanel != null) { + battlePanel.setBattlesAndBombing(battles); + } changeTo(gamePlayer, battlePanel); } @@ -182,9 +187,9 @@ private void changeTo(final GamePlayer gamePlayer, final @Nullable ActionPanel n actionPanel = newCurrent; // newCurrent might be null if we are shutting down - if (actionPanel != null) { - actionPanel.display(gamePlayer); - SwingUtilities.invokeLater(() -> layout.show(ActionButtons.this, actionPanel.toString())); + if (newCurrent != null) { + newCurrent.display(gamePlayer); + SwingUtilities.invokeLater(() -> layout.show(ActionButtons.this, newCurrent.toString())); } } @@ -198,6 +203,9 @@ public void changeToPickTerritoryAndUnits(final GamePlayer gamePlayer) { * @return null if no move was made. */ public IntegerMap waitForPurchase(final boolean bid) { + if (purchasePanel == null) { + return null; + } return purchasePanel.waitForPurchase(bid); } @@ -208,6 +216,9 @@ public IntegerMap waitForPurchase(final boolean bid) { */ public Map> waitForRepair( final boolean bid, final Collection allowedPlayersToRepair) { + if (repairPanel == null) { + return null; + } return repairPanel.waitForRepair(bid, allowedPlayersToRepair); } @@ -217,6 +228,9 @@ public Map> waitForRepair( * @return null if no move was made. */ public MoveDescription waitForMove(final IPlayerBridge bridge) { + if (movePanel == null) { + return null; + } return movePanel.waitForMove(bridge); } @@ -226,6 +240,9 @@ public MoveDescription waitForMove(final IPlayerBridge bridge) { * @return null if no tech roll was made. */ public TechRoll waitForTech() { + if (techPanel == null) { + return null; + } return techPanel.waitForTech(); } @@ -236,6 +253,9 @@ public TechRoll waitForTech() { */ public PoliticalActionAttachment waitForPoliticalAction( final boolean firstRun, final IPoliticsDelegate politicsDelegate) { + if (politicsPanel == null) { + return null; + } return politicsPanel.waitForPoliticalAction(firstRun, politicsDelegate); } @@ -246,6 +266,9 @@ public PoliticalActionAttachment waitForPoliticalAction( */ public UserActionAttachment waitForUserActionAction( final boolean firstRun, final IUserActionDelegate userActionDelegate) { + if (userActionPanel == null) { + return null; + } return userActionPanel.waitForUserActionAction(firstRun, userActionDelegate); } @@ -255,20 +278,30 @@ public UserActionAttachment waitForUserActionAction( * @return null if no placement was made. */ public PlaceData waitForPlace(final boolean bid, final IPlayerBridge bridge) { + if (placePanel == null) { + return null; + } return placePanel.waitForPlace(bid, bridge); } /** Blocks until the user selects an end-of-turn action. */ public void waitForEndTurn(final TripleAFrame frame, final IPlayerBridge bridge) { - endTurnPanel.waitForDone(frame, bridge); + if (endTurnPanel != null) { + endTurnPanel.waitForDone(frame, bridge); + } } public void waitForMoveForumPosterPanel(final TripleAFrame frame, final IPlayerBridge bridge) { - moveForumPosterPanel.waitForDone(frame, bridge); + if (moveForumPosterPanel != null) { + moveForumPosterPanel.waitForDone(frame, bridge); + } } /** Blocks until the user selects a battle to fight. */ public FightBattleDetails waitForBattleSelection() { + if (battlePanel == null) { + return null; + } return battlePanel.waitForBattleSelection(); } @@ -276,6 +309,9 @@ public Tuple> waitForPickTerritoryAndUnits( final List territoryChoices, final List unitChoices, final int unitsPerPick) { + if (pickTerritoryAndUnitsPanel == null) { + return Tuple.of(null, Set.of()); + } return pickTerritoryAndUnitsPanel.waitForPickTerritoryAndUnits( territoryChoices, unitChoices, unitsPerPick); }