diff --git a/vassal-app/src/main/java/VASSAL/build/module/PlayerRoster.java b/vassal-app/src/main/java/VASSAL/build/module/PlayerRoster.java index 8620bdc4a0..7684b7343c 100644 --- a/vassal-app/src/main/java/VASSAL/build/module/PlayerRoster.java +++ b/vassal-app/src/main/java/VASSAL/build/module/PlayerRoster.java @@ -107,11 +107,12 @@ public PlayerRoster() { setHotKeyKey(BUTTON_KEYSTROKE); setLaunchButton(makeLaunchButton( - Resources.getString("PlayerRoster.allow_another"), - Resources.getString("PlayerRoster.retire"), - "", - e -> launch() + Resources.getString("PlayerRoster.allow_another"), + Resources.getString("PlayerRoster.retire"), + "", + e -> launch() )); + getLaunchButton().setEnabled(false); // not usuable without a game retireButton = getLaunchButton(); // for compatibility @@ -266,14 +267,12 @@ protected void launch() { return; } - String newSide; - newSide = promptForSide(); + String newSide = promptForSide(); if ((newSide == null) || newSide.equals(mySide)) { return; } final GameModule gm = GameModule.getGameModule(); - // Avoid bug that allowed gaining access to a hidden/locked side if (GameModule.getGameModule().getGameState().isLoadingInBackground()) { return; @@ -488,18 +487,23 @@ public void finish() { GameModule.getGameModule().warn(Resources.getString("PlayerRoster.failed_pref_write", e.getLocalizedMessage())); } - final String newSide = untranslateSide(sideConfig.getValueString()); + // Drop into standard routine, starting with checking that the side is still available (race condition mitigation) + // returns untranslated side + final String newSide = promptForSide(sideConfig.getValueString()); + + // null is a cancel op - player will not connect to the game if (newSide != null) { if (GameModule.getGameModule().isMultiplayerConnected()) { - final Command c = new Chatter.DisplayText(GameModule.getGameModule().getChatter(), Resources.getString(GlobalOptions.getInstance().chatterHTMLSupport() ? "PlayerRoster.joined_side_2" : "PlayerRoster.joined_side", GameModule.getGameModule().getPrefs().getValue(GameModule.REAL_NAME), newSide)); + final Command c = new Chatter.DisplayText(GameModule.getGameModule().getChatter(), Resources.getString(GlobalOptions.getInstance().chatterHTMLSupport() ? "PlayerRoster.joined_side_2" : "PlayerRoster.joined_side", GameModule.getGameModule().getPrefs().getValue(GameModule.REAL_NAME), translateSide(newSide))); c.execute(); } final Add a = new Add(this, GameModule.getActiveUserId(), GlobalOptions.getInstance().getPlayerId(), newSide); a.execute(); GameModule.getGameModule().getServer().sendToOthers(a); + + pickedSide = true; } - pickedSide = true; } @Override @@ -782,53 +786,90 @@ public List getAvailableSides() { } protected String promptForSide() { - // availableSides and alreadyTaken are Translated side names + return promptForSide(""); + } + + protected String promptForSide(String newSide) { + // availableSides and alreadyTaken are translated side names final ArrayList availableSides = new ArrayList<>(getSides()); final ArrayList alreadyTaken = new ArrayList<>(); + boolean alreadyConnected; + final GameModule g = GameModule.getGameModule(); - for (final PlayerInfo p : players) { - alreadyTaken.add(p.getLocalizedSide()); + if (newSide != null && newSide.isEmpty()) { + alreadyConnected = true; } - - availableSides.removeAll(alreadyTaken); - - // If a "real" player side is available, we want to offer "the next one" as the default, rather than observer. - // Thus hotseat players can easily cycle through the player positions as they will appear successively as the default. - // Common names for Solitaire players (Solitaire, Solo, Referee) do not count as "real" player sides, and will be skipped. - // If we have no "next" side available to offer, we stay with the observer side as our default offering. - boolean found = false; // If we find a usable side - final String mySide = getMyLocalizedSide(); // Get our own side, so we can find the "next" one - final int myidx = (mySide != null) ? sides.indexOf(mySide) : -1; // See if we have a current non-observe side. - int i = (myidx >= 0) ? ((myidx + 1) % sides.size()) : 0; // If we do, start looking in the "next" slot, otherwise start at beginning. - for (int tries = 0; i != myidx && tries < sides.size(); i = (i + 1) % sides.size(), tries++) { // Wrap-around search of sides - final String s = sides.get(i); - if (!alreadyTaken.contains(s) && !isSoloSide(untranslateSide(s))) { - found = true; // Found an available slot that's not our current one and not a "solo" slot. - break; + else { + if (newSide == null || translatedObserver.equals(newSide)) { // Observer checked and returned translated here + return OBSERVER; + } + else { + alreadyConnected = false; } } - final String nextChoice = found ? sides.get(i) : translatedObserver; // This will be our defaulted choice for the dropdown. + while (newSide != null) { // Loops until a valid side is found or op is canceled (repeats side check to minimuse race condition window) + // Refresh from current game state + for (final PlayerInfo p : players) { + alreadyTaken.add(p.getLocalizedSide()); + } - availableSides.add(0, translatedObserver); + /* + The while loop ensures that the selected side is re-checked here and only returned if the side is still available. + This prevents players switching to the same side if they enter the switch-side dialogue (below) at the same time, + narrowing the race condition window to network latency. + */ + if (!newSide.isEmpty() && !alreadyTaken.contains(newSide)) { + // side is returned in English for sharing in the game. + newSide = untranslateSide(newSide); + break; + } + else { + // Set up for another try... + availableSides.clear(); + availableSides.addAll(sides); + } - final GameModule g = GameModule.getGameModule(); - String newSide = (String) JOptionPane.showInputDialog( - g.getPlayerWindow(), - Resources.getString("PlayerRoster.switch_sides", getMyLocalizedSide()), //$NON-NLS-1$ - Resources.getString("PlayerRoster.choose_side"), //$NON-NLS-1$ - JOptionPane.QUESTION_MESSAGE, - null, - availableSides.toArray(new String[0]), - nextChoice // Offer calculated most likely "next side" as the default - ); + availableSides.removeAll(alreadyTaken); + String nextChoice = translatedObserver; // default for dropdown + + // When player is already connected, offer a hot-seat... + // If a "real" player side is available, we want to offer "the next one" as the default, rather than observer. + // Thus hotseat players can easily cycle through the player positions as they will appear successively as the default. + // Common names for Solitaire players (Solitaire, Solo, Referee) do not count as "real" player sides, and will be skipped. + // If we have no "next" side available to offer, we stay with the observer side as our default offering. + if (alreadyConnected) { + final String mySide = getMyLocalizedSide(); // Get our own side, so we can find the "next" one + final int myidx = (mySide != null) ? sides.indexOf(mySide) : -1; // See if we have a current non-observe side. + int i = (myidx >= 0) ? ((myidx + 1) % sides.size()) : 0; // If we do, start looking in the "next" slot, otherwise start at beginning. + for (int tries = 0; i != myidx && tries < sides.size(); i = (i + 1) % sides.size(), tries++) { // Wrap-around search of sides + final String s = sides.get(i); + if (!alreadyTaken.contains(s) && !isSoloSide(untranslateSide(s))) { + nextChoice = sides.get(i); // Found an available slot that's not our current one and not a "solo" slot. + break; + } + } + } - // sides must always be stored internally in English. - if (translatedObserver.equals(newSide)) { - newSide = OBSERVER; - } - else { - newSide = untranslateSide(newSide); + availableSides.add(0, translatedObserver); + + newSide = (String) JOptionPane.showInputDialog( + g.getPlayerWindow(), + newSide.isEmpty() ? Resources.getString("PlayerRoster.switch_sides", getMyLocalizedSide()) : Resources.getString("PlayerRoster.switch_sides2", newSide, getMyLocalizedSide()), //$NON-NLS-1$ + Resources.getString("PlayerRoster.choose_side"), //$NON-NLS-1$ + JOptionPane.QUESTION_MESSAGE, + null, + availableSides.toArray(new String[0]), + nextChoice // Offer calculated most likely "next side" as the default + ); + + // side must be returned in English + if (translatedObserver.equals(newSide)) { // Observer returns here, other returns are checked once more. + return OBSERVER; + } + else { + alreadyTaken.clear(); // prepare to loop again for exit check + } } return newSide; } diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties index 637cc737d3..213217f602 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL.properties @@ -1025,6 +1025,7 @@ PlayerRoster.join_another_side=Join another side PlayerRoster.give_up_position=Give up your position as %1$s? PlayerRoster.join_game_as=Join game as which side? PlayerRoster.switch_sides=Your current side is %1$s. Switch to which side? +PlayerRoster.switch_sides2=%1$s is now unavailable. Your current side is %2$s. Switch to which side? PlayerRoster.choose_side=Choose side PlayerRoster.observer= PlayerRoster.solitaire=Solitaire diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_da.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_da.properties index 0c3036309a..9a3bc4d31c 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_da.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_da.properties @@ -1023,6 +1023,7 @@ PlayerRoster.join_another_side=Skift til en anden side PlayerRoster.give_up_position=Vil du opgive din position som %1$s? PlayerRoster.join_game_as=Join spillet på hvilken side? PlayerRoster.switch_sides=Din nuværende side er %1$s. Skift til hvilken side? +PlayerRoster.switch_sides2=%1$s er nu ikke tilgængelig. Din nuværende side er %2$s. Skift til hvilken side? PlayerRoster.choose_side=Vælg side PlayerRoster.observer= PlayerRoster.solitaire=Solitaire diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_de.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_de.properties index 99d8f75116..537fe6b6d5 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_de.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_de.properties @@ -547,6 +547,8 @@ PlayerRoster.become_observer=Werde zum Beobachter PlayerRoster.join_another_side=Wechsle zu anderer Seite PlayerRoster.give_up_position=Nicht mehr weiterspielen als %1$s? PlayerRoster.join_game_as=Spiele auf welcher Seite? +PlayerRoster.switch_sides=Ihre aktuelle Seite ist %1$s. Auf welche Seite wechseln? +PlayerRoster.switch_sides2=%1$s ist nicht verfügbar. Ihre aktuelle Seite ist %2$s. Auf welche Seite wechseln? PlayerRoster.choose_side=Wähle Seite PlayerRoster.observer= diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_es.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_es.properties index aa541423f0..b7f221bff4 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_es.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_es.properties @@ -704,6 +704,7 @@ PlayerRoster.join_another_side=Unirse a otro bando PlayerRoster.give_up_position=¿Dejar su lugar como %1$s? PlayerRoster.join_game_as=Unirse a la partida como... PlayerRoster.switch_sides=Tu bando actual es %1$s. ¿Cambiar de bando? +PlayerRoster.switch_sides2=%1$s no está disponible. Tu bando actual es %2$s. ¿Cambiar de bando? PlayerRoster.choose_side=Seleccione su bando PlayerRoster.observer= PlayerRoster.solitaire=Solitario diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_fr.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_fr.properties index 3543034489..05c87b9d78 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_fr.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_fr.properties @@ -782,6 +782,7 @@ PlayerRoster.join_another_side=Changer de camp PlayerRoster.give_up_position=Abandonner sa position comme %1$s PlayerRoster.join_game_as=Rejoindre la partie en prenant quel camp ? PlayerRoster.switch_sides=Votre camp actuel est %1$s. Basculer vers quel camp? +PlayerRoster.switch_sides2=%1$s n'est pas disponible. Votre camp actuel est %2$s. Basculer vers quel camp? PlayerRoster.choose_side=Choisissez votre camp PlayerRoster.observer= PlayerRoster.solitaire=Solitaire diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_it.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_it.properties index 70ff595cf5..c21688d2a3 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_it.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_it.properties @@ -601,6 +601,8 @@ PlayerRoster.become_observer=Diventa osservatore PlayerRoster.join_another_side=Cambia fazione PlayerRoster.give_up_position=Abbandoni la fazione di %1$s? PlayerRoster.join_game_as=Scelta fazione +PlayerRoster.switch_sides=Il tuo lato attuale è %1$s. Passare da che parte? +PlayerRoster.switch_sides2=%1$s non è disponibile. Il tuo lato attuale è %2$s. Passare da che parte? PlayerRoster.choose_side=Scegli la fazione PlayerRoster.observer= PlayerRoster.referee=Arbitro diff --git a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_nl.properties b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_nl.properties index 352c5195a7..1fd0cb7177 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_nl.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/VASSAL_nl.properties @@ -527,6 +527,8 @@ PlayerRoster.become_observer=Wordt kijker PlayerRoster.join_another_side=Kies een andere kant PlayerRoster.give_up_position=Geef uw positie op als %1$s? PlayerRoster.join_game_as=Doe mee aan welke kant? +PlayerRoster.switch_sides=Je huidige kant is %1$s. Naar welke kant overstappen? +PlayerRoster.switch_sides2=%1$s is niet beschikbaar. Je huidige kant is %2$s. Naar welke kant overstappen? PlayerRoster.choose_side=Kies kant PlayerRoster.observer= diff --git a/vassal-app/src/test/resources/test-images/cc.png b/vassal-app/src/test/resources/test-images/cc.png index fa26f23306..e69de29bb2 100644 Binary files a/vassal-app/src/test/resources/test-images/cc.png and b/vassal-app/src/test/resources/test-images/cc.png differ diff --git a/vassal-doc/src/main/readme-referencemanual/ReferenceManual/GameModule.adoc b/vassal-doc/src/main/readme-referencemanual/ReferenceManual/GameModule.adoc index 8650cec158..34ca7c8558 100644 --- a/vassal-doc/src/main/readme-referencemanual/ReferenceManual/GameModule.adoc +++ b/vassal-doc/src/main/readme-referencemanual/ReferenceManual/GameModule.adoc @@ -116,7 +116,11 @@ Simply type a name for each side and refer to that name in the restricted compon Only one player may be assigned to a side. When joining a game, players will be prompted to take one of the remaining available sides. Any number of observers (players who belong to no side) are allowed. + The <> or <> button, in the main controls toolbar, allows a player to relinquish their side (making it available to the next player joining the game). You can specify the text, icon, and mouse-over tooltip for the toolbar button. + +The Switch Sides component includes hot-seat support. Vassal will determine the next side in order, excluding non-player sides "Solo", "Solitaire", "Moderator" and "Referee". + |=== ==== <>