Skip to content

Commit

Permalink
Merge pull request #12667 from riverwanderer/12542-side-switch-race-m…
Browse files Browse the repository at this point in the history
…itigation

12542 side switch race mitigation
  • Loading branch information
uckelman authored Sep 20, 2023
2 parents 470a145 + dafa3c2 commit 02ee482
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 47 deletions.
135 changes: 88 additions & 47 deletions vassal-app/src/main/java/VASSAL/build/module/PlayerRoster.java
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -782,53 +786,90 @@ public List<String> 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<String> availableSides = new ArrayList<>(getSides());
final ArrayList<String> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<observer>
PlayerRoster.solitaire=Solitaire
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<tilskuer>
PlayerRoster.solitaire=Solitaire
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<Beobachter>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<Observador>
PlayerRoster.solitaire=Solitario
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<Observateur>
PlayerRoster.solitaire=Solitaire
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<osservatore>
PlayerRoster.referee=Arbitro
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<kijker>

Expand Down
Binary file modified vassal-app/src/test/resources/test-images/cc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<Toolbar.adoc#Retire,Retire>> or <<Toolbar.adoc#SwitchSides,Switch Sides>> 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".

|===

==== <<GlobalOptions.adoc#top,Global Options>>
Expand Down

0 comments on commit 02ee482

Please sign in to comment.