Repository: CataniaUnited/catania-united-server
Pull request #53: #19 - Selection of unique player color
Author: login: evelynav
Created: 2025-04-18T11:41:54Z | Merged: 2025-04-23T07:03:37Z

1. 12 different player colors implemented: 

            - RED("#BD2828")
            - LIGHT_RED("FF9999")
            - PURPLE("#B62FB2")
            - LAVENDER("CCCCFF")
            - BLUE("#0080B3")
            - LIGHT_BLUE("B1DAE7")
            - GREEN("#008000")
            - LIGHT_GREEN("A7F1A7")
            - ORANGE("#CF6D17")
            - LIGHT_ORANGE("FFCC99")
            - YELLOW("#DB9224")
            - GREY("#999999")

2. Assigned a unique random color to each player when joining a lobby
3. Implemented removing a player from a lobby
4. Added color back to available colors when removing a player from a lobby
5. Reset player colors when clearing lobbies


Diff:
diff --git a/src/main/java/com/example/cataniaunited/lobby/Lobby.java b/src/main/java/com/example/cataniaunited/lobby/Lobby.java
index 89711848..3c0ce686 100644
--- a/src/main/java/com/example/cataniaunited/lobby/Lobby.java
+++ b/src/main/java/com/example/cataniaunited/lobby/Lobby.java
@@ -1,15 +1,21 @@
 package com.example.cataniaunited.lobby;
 
-import java.util.Set;
+import java.util.*;
 import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ConcurrentHashMap;
+import com.example.cataniaunited.player.PlayerColor;
 
 public class Lobby {
     private final String lobbyId;
     private final Set<String> players = new CopyOnWriteArraySet<>();
+    private final Map<String, PlayerColor> playerColors = new ConcurrentHashMap<>();
+    private final List<PlayerColor> availableColors = new CopyOnWriteArrayList<>();
 
     public Lobby(String lobbyId, String hostPlayer) {
         this.lobbyId = lobbyId;
         this.players.add(hostPlayer);
+        Collections.addAll(availableColors, PlayerColor.values());
     }
 
     public String getLobbyId() {
@@ -21,6 +27,38 @@ public Set<String> getPlayers() {
     }
 
     public void addPlayer(String player){
-        players.add(player);
+       players.add(player);
+    }
+
+    public boolean removePlayer(String player){
+        return players.remove(player);
+    }
+
+    public List<PlayerColor> getAvailableColors(){
+        return availableColors;
+    }
+
+    public void setPlayerColor(String player, PlayerColor color){
+        playerColors.put(player, color);
+    }
+
+    public PlayerColor getPlayerColor (String player){
+        return playerColors.get(player);
+    }
+
+    public void removePlayerColor(String player){
+        playerColors.remove(player);
+    }
+
+    public PlayerColor assignAvailableColor(){
+        if(availableColors.isEmpty()) return null;
+        Collections.shuffle(availableColors);
+        return availableColors.remove(0);
+    }
+
+    public void restoreColor(PlayerColor color){
+        if(color != null && !availableColors.contains(color)){
+            availableColors.add(color);
+        }
     }
 }
\ No newline at end of file
diff --git a/src/main/java/com/example/cataniaunited/lobby/LobbyService.java b/src/main/java/com/example/cataniaunited/lobby/LobbyService.java
index 48b1074a..2f7f6b5e 100644
--- a/src/main/java/com/example/cataniaunited/lobby/LobbyService.java
+++ b/src/main/java/com/example/cataniaunited/lobby/LobbyService.java
@@ -9,6 +9,7 @@ public interface LobbyService {
     String generateLobbyId();
     List<String> getOpenLobbies();
     boolean joinLobbyByCode(String lobbyId, String player);
+    void removePlayerFromLobby(String lobbyId, String player);
     Lobby getLobbyById(String lobbyId) throws GameException;
     void clearLobbies();
 }
\ No newline at end of file
diff --git a/src/main/java/com/example/cataniaunited/lobby/LobbyServiceImpl.java b/src/main/java/com/example/cataniaunited/lobby/LobbyServiceImpl.java
index ed3cc7ac..eadec9aa 100644
--- a/src/main/java/com/example/cataniaunited/lobby/LobbyServiceImpl.java
+++ b/src/main/java/com/example/cataniaunited/lobby/LobbyServiceImpl.java
@@ -1,6 +1,7 @@
 package com.example.cataniaunited.lobby;
 
 import com.example.cataniaunited.exception.GameException;
+import com.example.cataniaunited.player.PlayerColor;
 import jakarta.enterprise.context.ApplicationScoped;
 import org.jboss.logging.Logger;
 
@@ -15,6 +16,7 @@ public class LobbyServiceImpl implements LobbyService {
     private final Map<String, Lobby> lobbies = new ConcurrentHashMap<>();
     private static final SecureRandom secureRandom = new SecureRandom();
 
+
     @Override
     public String createLobby(String hostPlayer) {
         String lobbyId;
@@ -61,8 +63,15 @@ public List<String> getOpenLobbies() {
     public boolean joinLobbyByCode(String lobbyId, String player) {
         Lobby lobby = lobbies.get(lobbyId);
         if(lobby != null){
+            PlayerColor assignedColor = lobby.assignAvailableColor();
+            if(assignedColor == null){
+                logger.warnf("No colors available for new players in lobby %s.", lobbyId);
+                return false;
+            }
             lobby.addPlayer(player);
-            logger.infof("Player %s joined lobby %s", player, lobbyId);
+            lobby.setPlayerColor(player, assignedColor);
+
+            logger.infof("Player %s joined lobby %s with color %s", player, lobbyId, assignedColor);
             return true;
         }
 
@@ -70,6 +79,24 @@ public boolean joinLobbyByCode(String lobbyId, String player) {
         return false;
     }
 
+    public void removePlayerFromLobby(String lobbyId, String player){
+        Lobby lobby = lobbies.get(lobbyId);
+        if(lobby != null){
+            PlayerColor color = lobby.getPlayerColor(player);
+
+            if(color != null){
+                lobby.restoreColor(color);
+                logger.infof("Color %s returned to pool from player %s", color, player);
+            }
+
+            lobby.removePlayer(player);
+            lobby.removePlayerColor(player);
+
+            logger.infof("Player %s removed from lobby %s", player, lobbyId);
+        }
+        logger.warnf("Attempted to remove player from non-existing lobby: %s", lobbyId);
+    }
+
     @Override
     public Lobby getLobbyById(String lobbyId) throws GameException {
         Lobby lobby = lobbies.get(lobbyId);
diff --git a/src/main/java/com/example/cataniaunited/player/PlayerColor.java b/src/main/java/com/example/cataniaunited/player/PlayerColor.java
new file mode 100644
index 00000000..ccfa6a40
--- /dev/null
+++ b/src/main/java/com/example/cataniaunited/player/PlayerColor.java
@@ -0,0 +1,25 @@
+package com.example.cataniaunited.player;
+
+public enum PlayerColor {
+    RED("#BD2828"),
+    LIGHT_RED("FF9999"),
+    PURPLE("#B62FB2"),
+    LAVENDER("CCCCFF"),
+    BLUE("#0080B3"),
+    LIGHT_BLUE("B1DAE7"),
+    GREEN("#008000"),
+    LIGHT_GREEN("A7F1A7"),
+    ORANGE("#CF6D17"),
+    LIGHT_ORANGE("FFCC99"),
+    YELLOW("#DB9224"),
+    GREY("#999999");
+
+    private final String hexCode;
+    PlayerColor(String hexCode) {
+        this.hexCode = hexCode;
+    }
+
+    public String getHexCode(){
+        return hexCode;
+    }
+}
diff --git a/src/test/java/com/example/cataniaunited/api/GameWebSocketTest.java b/src/test/java/com/example/cataniaunited/api/GameWebSocketTest.java
index e700b9af..b6a27598 100644
--- a/src/test/java/com/example/cataniaunited/api/GameWebSocketTest.java
+++ b/src/test/java/com/example/cataniaunited/api/GameWebSocketTest.java
@@ -320,6 +320,42 @@ void testJoinLobbySuccess() throws InterruptedException, JsonProcessingException
         assertTrue(latch.await(5, TimeUnit.SECONDS), "Did not receive PLAYER_JOINED message in time");
     }
 
+    @Test
+    void testPlayerJoinedLobbySuccess() throws JsonProcessingException, InterruptedException {
+        String player = "TestPlayer";
+        String lobbyId = "xyz123";
+
+        doReturn(true).when(lobbyService).joinLobbyByCode(lobbyId, player);
+
+        MessageDTO joinLobbyMessage = new MessageDTO(MessageType.JOIN_LOBBY, player, lobbyId);
+
+        List<String> receivedMessages = new ArrayList<>();
+        CountDownLatch messageLatch = new CountDownLatch(2);
+
+        var webSocketClientConnection = BasicWebSocketConnector.create()
+                .baseUri(serverUri)
+                .path("/game")
+                .onTextMessage((connection, message) -> {
+                    if(message.startsWith("{")){
+                        receivedMessages.add(message);
+                        messageLatch.countDown();
+                    }
+                })
+                .connectAndAwait();
+
+        String sentMessage = objectMapper.writeValueAsString(joinLobbyMessage);
+        webSocketClientConnection.sendTextAndAwait(sentMessage);
+
+        assertTrue(messageLatch.await(5, TimeUnit.SECONDS), "Not all messages were received in time!");
+        assertEquals(2, receivedMessages.size());
+
+        MessageDTO responseMessage = objectMapper.readValue(receivedMessages.getLast(), MessageDTO.class);
+
+        assertEquals(MessageType.PLAYER_JOINED, responseMessage.getType());
+        assertEquals(player, responseMessage.getPlayer());
+        assertEquals(lobbyId, responseMessage.getLobbyId());
+    }
+
     @Test
     void testJoinLobbyFailure() throws InterruptedException, JsonProcessingException {
         MessageDTO joinLobbyMessage = new MessageDTO(MessageType.JOIN_LOBBY, "Player 1", "invalidLobbyId");
diff --git a/src/test/java/com/example/cataniaunited/lobby/LobbyServiceImplTest.java b/src/test/java/com/example/cataniaunited/lobby/LobbyServiceImplTest.java
index 2305b720..eeb5e88a 100644
--- a/src/test/java/com/example/cataniaunited/lobby/LobbyServiceImplTest.java
+++ b/src/test/java/com/example/cataniaunited/lobby/LobbyServiceImplTest.java
@@ -1,6 +1,7 @@
 package com.example.cataniaunited.lobby;
 
 import com.example.cataniaunited.exception.GameException;
+import com.example.cataniaunited.player.PlayerColor;
 import io.quarkus.test.junit.QuarkusTest;
 import jakarta.inject.Inject;
 import org.jboss.logging.Logger;
@@ -94,4 +95,63 @@ void testJoinLobbyByInvalidCode (){
         assertFalse(joined, "Player should not be able to join the lobby with a valid code");
     }
 
+    @Test
+    void testJoinLobbyAssignsUniqueColor() throws GameException {
+        String lobbyId = lobbyService.createLobby("HostPlayer");
+        lobbyService.joinLobbyByCode(lobbyId, "Player1");
+
+        Lobby lobby = lobbyService.getLobbyById(lobbyId);
+        assertTrue(lobby.getPlayers().contains("Player1"));
+
+        PlayerColor color = lobby.getPlayerColor("Player1");
+
+        assertNotNull(color);
+        assertFalse(lobbyService.getOpenLobbies().isEmpty());
+    }
+
+    @Test
+    void testJoinLobbyFailsWhenNoColorsAvailable (){
+        String lobbyId = lobbyService.createLobby("HostPlayer");
+
+        for (int i = 0; i < PlayerColor.values().length; i++) {
+            lobbyService.joinLobbyByCode(lobbyId, "Player" + i);
+        }
+
+        boolean joined = lobbyService.joinLobbyByCode(lobbyId, "ExtraPlayer");
+        assertFalse(joined, "Player should not be able to join when no colors are available");
+    }
+
+    @Test
+    void testRemovePlayerFromLobby() throws GameException {
+        String lobbyId = lobbyService.createLobby("HostPlayer");
+        lobbyService.joinLobbyByCode(lobbyId, "Player1");
+
+        Lobby lobby = lobbyService.getLobbyById(lobbyId);
+        assertTrue(lobby.getPlayers().contains("Player1"));
+
+        lobbyService.removePlayerFromLobby(lobbyId, "Player1");
+        assertFalse(lobby.getPlayers().contains("Player1"));
+    }
+
+    @Test
+    void testRemovePlayerRestoresColor(){
+        String lobbyId = lobbyService.createLobby("HostPlayer");
+        lobbyService.joinLobbyByCode(lobbyId, "Player1");
+
+        int colorPoolSizeBefore = PlayerColor.values().length - 2;
+        lobbyService.removePlayerFromLobby(lobbyId, "Player1");
+
+        assertEquals(PlayerColor.values().length-1, colorPoolSizeBefore + 1);
+    }
+
+    @Test
+    void testRemovePlayerNotInLobby() {
+        String lobbyId = lobbyService.createLobby("HostPlayer");
+
+        lobbyService.removePlayerFromLobby(lobbyId, "GhostPlayer");
+
+        Lobby lobby = assertDoesNotThrow(() -> lobbyService.getLobbyById(lobbyId));
+        assertFalse(lobby.getPlayers().contains("GhostPlayer"));
+    }
+
 }
\ No newline at end of file
diff --git a/src/test/java/com/example/cataniaunited/lobby/LobbyTest.java b/src/test/java/com/example/cataniaunited/lobby/LobbyTest.java
index d8f5b2c8..a20b1eb7 100644
--- a/src/test/java/com/example/cataniaunited/lobby/LobbyTest.java
+++ b/src/test/java/com/example/cataniaunited/lobby/LobbyTest.java
@@ -1,5 +1,6 @@
 package com.example.cataniaunited.lobby;
 
+import com.example.cataniaunited.player.PlayerColor;
 import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.*;
 import java.util.Set;
@@ -28,4 +29,21 @@ void getPlayers_shouldReturnHostPlayer() {
         assertEquals(1, players.size());
         assertTrue(players.contains(hostPlayer));
     }
+
+    @Test
+    void testRestoreColor_shouldOnlyAddColorIfNotPresent(){
+        Lobby lobby = new Lobby("555xyz", "HostPlayer");
+
+        PlayerColor color = lobby.assignAvailableColor();
+        assertNotNull(color);
+
+        lobby.restoreColor(color);
+
+        int countBefore = (int) lobby.getAvailableColors().stream().filter(c -> c == color).count();
+        lobby.restoreColor(color);
+        int countAfter = (int) lobby.getAvailableColors().stream().filter(c -> c == color).count();
+
+        assertEquals(1, countAfter);
+        assertEquals(countBefore, countAfter);
+    }
 }
diff --git a/src/test/java/com/example/cataniaunited/player/PlayerColorTest.java b/src/test/java/com/example/cataniaunited/player/PlayerColorTest.java
new file mode 100644
index 00000000..d3625598
--- /dev/null
+++ b/src/test/java/com/example/cataniaunited/player/PlayerColorTest.java
@@ -0,0 +1,25 @@
+package com.example.cataniaunited.player;
+
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+class PlayerColorTest {
+    @Test
+    void testHexCodesNotNullOrEmpty(){
+        for (PlayerColor color: PlayerColor.values()) {
+            assertNotNull(color.getHexCode());
+            assertFalse(color.getHexCode().isEmpty());
+        }
+    }
+
+    @Test
+    void testUniqueHexCodes(){
+        Set<String> hexSet = new HashSet<>();
+        for (PlayerColor color : PlayerColor.values()){
+            assertTrue(hexSet.add(color.getHexCode()), "Duplicate hex code: " + color.getHexCode());
+        }
+    }
+}


Reviews:
user: login: w0lphi
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-18T12:49:53Z
user: login: w0lphi
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-22T16:37:57Z
user: login: w0lphi
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T23:29:49Z

Review comments:
diff_hunk: @@ -1,26 +1,37 @@
 package com.example.cataniaunited.lobby;
 
-import java.util.Set;
-import java.util.concurrent.CopyOnWriteArraySet;
+import com.example.cataniaunited.player.Player;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 
 public class Lobby {
     private final String lobbyId;
-    private final Set<String> players = new CopyOnWriteArraySet<>();
+    private final Map<String, Player> players = new ConcurrentHashMap<>();
path: src/main/java/com/example/cataniaunited/lobby/Lobby.java
user: login: w0lphi
body: Es sollte die `uniqueId` des Players statt dem `username` als Identifier für die Map verwendet werden, da der Username ja geändert werden kann.
created_at: 2025-04-18T12:48:10Z
author_association: MEMBER
diff_hunk: @@ -58,18 +65,41 @@ public List<String> getOpenLobbies() {
     }
 
     @Override
-    public boolean joinLobbyByCode(String lobbyId, String player) {
+    public boolean joinLobbyByCode(String lobbyId, String username) {
         Lobby lobby = lobbies.get(lobbyId);
         if(lobby != null){
+            if(availableColors.isEmpty()){
+                logger.warnf("No colors available for new players.");
+                return false;
+            }
+            Player player = new Player(username);
+
+            Collections.shuffle(availableColors);
+            PlayerColor assignedColor = availableColors.remove(0);
+            player.setColor(assignedColor);
+
             lobby.addPlayer(player);
-            logger.infof("Player %s joined lobby %s", player, lobbyId);
+            logger.infof("Player %s joined lobby %s with color %s", username, lobbyId, assignedColor);
             return true;
         }
 
         logger.warnf("Invalid or expired lobby ID: %s", lobbyId);
         return false;
     }
 
+    public void removePlayerFromLobby(String lobbyId, String username){
path: src/main/java/com/example/cataniaunited/lobby/LobbyServiceImpl.java
user: login: w0lphi
body: Wie oben gesagt sollte zur Identifizierung des Players die `uniqueId` verwendet werden.
created_at: 2025-04-18T12:48:46Z
author_association: MEMBER
diff_hunk: @@ -13,8 +14,13 @@ public class LobbyServiceImpl implements LobbyService {
 
     private static final Logger logger = Logger.getLogger(LobbyServiceImpl.class);
     private final Map<String, Lobby> lobbies = new ConcurrentHashMap<>();
+    private final List<PlayerColor> availableColors = new ArrayList<>();
path: src/main/java/com/example/cataniaunited/lobby/LobbyServiceImpl.java
user: login: w0lphi
body: Wenn die "availableColors" Liste wie hier im `LobbyService` liegt, teilen sich ALLE Lobbies die gleiche Liste an verfügbaren Farben d.h. wenn ein Spieler in Lobby A joined und die Farbe blau bekommt, kann in Lobby B (und jeder anderen) kein Spieler die Farbe Blau mehr bekommen. Die `availableColors` Liste sollte innerhalb der `Lobby` Klasse sein, damit jede Lobby eine eigene Liste an verfügbaren Farben besitzt und weiters solltest du die Thread-sichere Listen Implementierung `CopyOnWriteArrayList` statt `ArrayList` verwenden.
created_at: 2025-04-22T16:32:24Z
author_association: MEMBER
diff_hunk: @@ -1,11 +1,13 @@
 package com.example.cataniaunited.lobby;
 
-import java.util.Set;
+import java.util.*;
 import java.util.concurrent.CopyOnWriteArraySet;
+import com.example.cataniaunited.player.PlayerColor;
 
 public class Lobby {
     private final String lobbyId;
     private final Set<String> players = new CopyOnWriteArraySet<>();
+    private final Map<String, PlayerColor> playerColors = new HashMap<>();
path: src/main/java/com/example/cataniaunited/lobby/Lobby.java
user: login: w0lphi
body: Verwende hier die Thread-sichere Implementierung `ConcurrentHashMap` statt `HashMap`, damit es zu keinen Problemen kommt, falls zwei Spieler gleichzeitig einer Lobby beitreten.
created_at: 2025-04-22T16:37:45Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-22T18:39:35Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=CataniaUnited_catania-united-server&pullRequest=53) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=CataniaUnited_catania-united-server&pullRequest=53&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=CataniaUnited_catania-united-server&pullRequest=53&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=CataniaUnited_catania-united-server&pullRequest=53&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [96.8% Coverage on New Code](https://sonarcloud.io/component_measures?id=CataniaUnited_catania-united-server&pullRequest=53&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=CataniaUnited_catania-united-server&pullRequest=53&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=CataniaUnited_catania-united-server&pullRequest=53)


author_association: NONE