diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java index a1c2017..73d092c 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java @@ -30,6 +30,8 @@ public class GameController { private int dealerIndex = 0; + private volatile Runnable onGameEndedCallback; + private static final int DEALER_OFFSET = 1; private static final int SMALL_BLIND_OFFSET = 1; private static final int BIG_BLIND_OFFSET = 2; @@ -45,6 +47,11 @@ public class GameController { this.engine = engine; } + /** Set a callback to be invoked when the game ends. */ + public void setOnGameEndedCallback(Runnable callback) { + this.onGameEndedCallback = callback; + } + /** * Adds a player to the game with the specified name and initial chip count. * @@ -304,5 +311,12 @@ public class GameController { */ public void endGame() { engine.getState().setPhase(GamePhase.FINISHED); + if (onGameEndedCallback != null) { + try { + onGameEndedCallback.run(); + } catch (RuntimeException e) { + // Log or handle callback errors + } + } } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/Lobby.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/Lobby.java index 7aec356..8a0628b 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/Lobby.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/Lobby.java @@ -9,6 +9,8 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleEngine; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Level; import java.util.logging.Logger; @@ -26,7 +28,9 @@ public class Lobby { private final LobbyId id; private final String name; private final List playerNames = new CopyOnWriteArrayList<>(); + private final Set absentPlayers = ConcurrentHashMap.newKeySet(); private volatile GameController gameController; + private volatile Runnable onGameEndedCallback; public Lobby(LobbyId id, String name) { this.id = id; @@ -96,12 +100,91 @@ public class Lobby { public void initGame(GameController controller) { this.gameController = controller; + if (controller != null && onGameEndedCallback != null) { + controller.setOnGameEndedCallback(onGameEndedCallback); + } + } + + /** + * Set a callback to be invoked when the game in this lobby ends. This will be propagated to the + * game controller when a game is started. + */ + public void setOnGameEndedCallback(Runnable callback) { + this.onGameEndedCallback = callback; + if (gameController != null) { + gameController.setOnGameEndedCallback(callback); + } } public GameController getGameController() { return gameController; } + /** + * Check if a player is currently active (not absent) in the lobby. + * + * @param playerName the player to check + * @return true if player is in the active player list + */ + public boolean isPlayerActive(String playerName) { + return playerNames.contains(playerName); + } + + /** + * Mark a player as absent (left the lobby) while the game is still running. The player remains + * in the lobby's mappings but is moved to the absent set. + * + * @param playerName the player to mark as absent + * @return true if the player was active and is now absent + */ + public boolean leavePlayer(String playerName) { + if (playerName == null) { + return false; + } + boolean removed = playerNames.remove(playerName); + if (removed) { + absentPlayers.add(playerName); + } + return removed; + } + + /** + * Try to restore an absent player back to active. Returns true if player was absent and is now + * active again. + * + * @param playerName the player to restore + * @return true if player was restored from absent + */ + public boolean restoreAbsentPlayer(String playerName) { + if (playerName == null) { + return false; + } + boolean wasAbsent = absentPlayers.remove(playerName); + if (wasAbsent && !playerNames.contains(playerName)) { + playerNames.add(playerName); + return true; + } + return false; + } + + /** + * Get the set of absent (gone but not removed) players in this lobby. + * + * @return copy of absent players set + */ + public Set getAbsentPlayers() { + return Set.copyOf(absentPlayers); + } + + /** + * Check if this lobby has any players (active or absent). + * + * @return true if playerNames or absentPlayers is non-empty + */ + public boolean hasAnyPlayers() { + return !playerNames.isEmpty() || !absentPlayers.isEmpty(); + } + /** * Atomically add a player and, if the lobby reached {@code autoStartPlayers} and no game exists * yet, create and start a game. The operation is synchronized on the internal player list to @@ -139,6 +222,11 @@ public class Lobby { game.addPlayer(PlayerId.of(p), defaultStartChips); } + // Set the callback before starting the game + if (onGameEndedCallback != null) { + game.setOnGameEndedCallback(onGameEndedCallback); + } + game.startGame(); this.gameController = game; LOGGER.info(() -> "Auto-started game in lobby " + id.value()); diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyEventListener.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyEventListener.java index d6fee3a..84dab32 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyEventListener.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyEventListener.java @@ -4,4 +4,7 @@ package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; public interface LobbyEventListener { /** Called when a game is automatically started in the given lobby. */ void onGameStarted(LobbyId lobbyId); + + /** Called when a game in the given lobby ends (phase set to FINISHED). */ + void onGameEnded(LobbyId lobbyId); } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManager.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManager.java index 0e58adf..394e0d6 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManager.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManager.java @@ -71,6 +71,22 @@ public class LobbyManager { if (lobby == null) { return false; } + + // Check if player was absent and try to restore + if (lobby.getAbsentPlayers().contains(username)) { + boolean restored = lobby.restoreAbsentPlayer(username); + if (restored) { + LOGGER.info( + () -> + "User '" + + username + + "' rejoined absent slot in lobby " + + lobbyId.value()); + playerToLobby.put(username, lobbyId); + return true; + } + } + AddResult result = lobby.addPlayerAndMaybeStart( username, maxPlayersPerLobby, AUTO_START_PLAYERS, DEFAULT_START_CHIPS); @@ -94,6 +110,8 @@ public class LobbyManager { + AUTO_START_PLAYERS + " players; game started."); notifyGameStarted(lobbyId); + // Set the game-ended callback + notifySetGameEndedCallback(lobbyId); } return true; } @@ -121,6 +139,29 @@ public class LobbyManager { } } + private void notifySetGameEndedCallback(LobbyId lobbyId) { + Lobby lobby = activeLobbies.get(lobbyId); + if (lobby != null) { + lobby.setOnGameEndedCallback( + () -> { + notifyGameEnded(lobbyId); + }); + } + } + + private void notifyGameEnded(LobbyId lobbyId) { + for (LobbyEventListener l : listeners) { + try { + l.onGameEnded(lobbyId); + } catch (RuntimeException e) { + LOGGER.warning( + () -> + "Listener threw while handling game-end for lobby " + + lobbyId.value()); + } + } + } + public boolean removePlayer(String username) { LobbyId id = playerToLobby.remove(username); if (id == null) { @@ -138,6 +179,33 @@ public class LobbyManager { return removed; } + /** + * Mark a player as absent (left the lobby during a running game). The player is not removed but + * moved to the absent set, allowing them to rejoin later without losing their slot. + * + * @param username the player to mark as absent + * @param lobbyId the lobby they're leaving + * @return true if the player was marked absent successfully + */ + public boolean leavePlayerFromLobby(String username, LobbyId lobbyId) { + Lobby lobby = getLobby(lobbyId); + if (lobby == null) { + return false; + } + boolean left = lobby.leavePlayer(username); + if (left) { + LOGGER.info( + () -> + "User '" + + username + + "' left lobby " + + lobbyId.value() + + " (marked absent)"); + // Keep the playerToLobby mapping intact for rejoin + } + return left; + } + public Collection getAllLobbies() { return activeLobbies.values(); } @@ -169,6 +237,9 @@ public class LobbyManager { for (String username : removed.getPlayerNames()) { playerToLobby.remove(username); } + for (String username : removed.getAbsentPlayers()) { + playerToLobby.remove(username); + } } /** diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManagerTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManagerTest.java new file mode 100644 index 0000000..5675306 --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManagerTest.java @@ -0,0 +1,172 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for LobbyManager leave/rejoin functionality. */ +public class LobbyManagerTest { + private LobbyManager lobbyManager; + + @BeforeEach + void setUp() { + lobbyManager = new LobbyManager(4); + } + + @Test + void testLeavePlayerFromLobbyMarkingAbsent() { + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + assertNotNull(lobbyId); + + // Add a player + boolean joined = lobbyManager.addPlayerToLobby("Player1", lobbyId); + assertTrue(joined); + + Lobby lobby = lobbyManager.getLobby(lobbyId); + assertTrue(lobby.getPlayerNames().contains("Player1")); + assertTrue(lobby.getAbsentPlayers().isEmpty()); + + // Mark player as absent + boolean left = lobbyManager.leavePlayerFromLobby("Player1", lobbyId); + assertTrue(left); + + // Player should be in absent set + assertFalse(lobby.getPlayerNames().contains("Player1")); + assertTrue(lobby.getAbsentPlayers().contains("Player1")); + + // Player should still be in the playerToLobby mapping + Lobby retrievedLobby = lobbyManager.getLobbyByUsername("Player1"); + assertEquals(lobbyId, retrievedLobby.getId()); + } + + @Test + void testAbsentPlayerCanRejoin() { + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + + // Add players to fill the lobby almost + lobbyManager.addPlayerToLobby("Player1", lobbyId); + lobbyManager.addPlayerToLobby("Player2", lobbyId); + lobbyManager.addPlayerToLobby("Player3", lobbyId); + + Lobby lobby = lobbyManager.getLobby(lobbyId); + assertEquals(3, lobby.getPlayerNames().size()); + + // Player1 leaves + lobbyManager.leavePlayerFromLobby("Player1", lobbyId); + assertEquals(2, lobby.getPlayerNames().size()); + assertEquals(1, lobby.getAbsentPlayers().size()); + + // Player1 tries to rejoin - should succeed without error + boolean rejoined = lobbyManager.addPlayerToLobby("Player1", lobbyId); + assertTrue(rejoined); + + assertEquals(3, lobby.getPlayerNames().size()); + assertEquals(0, lobby.getAbsentPlayers().size()); + assertTrue(lobby.getPlayerNames().contains("Player1")); + } + + @Test + void testAbsentPlayerDoesNotCountAsFullLobby() { + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + + // Fill lobby completely + lobbyManager.addPlayerToLobby("P1", lobbyId); + lobbyManager.addPlayerToLobby("P2", lobbyId); + lobbyManager.addPlayerToLobby("P3", lobbyId); + lobbyManager.addPlayerToLobby("P4", lobbyId); + + Lobby lobby = lobbyManager.getLobby(lobbyId); + assertEquals(4, lobby.getPlayerNames().size()); + + // P1 leaves + lobbyManager.leavePlayerFromLobby("P1", lobbyId); + assertEquals(3, lobby.getPlayerNames().size()); + assertEquals(1, lobby.getAbsentPlayers().size()); + + // P1 can rejoin without "LOBBY_FULL" error + boolean rejoined = lobbyManager.addPlayerToLobby("P1", lobbyId); + assertTrue(rejoined); + } + + @Test + void testPlayerToLobbyMappingPreservedAfterLeave() { + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + lobbyManager.addPlayerToLobby("Player1", lobbyId); + + // Verify mapping exists + Lobby beforeLeave = lobbyManager.getLobbyByUsername("Player1"); + assertEquals(lobbyId, beforeLeave.getId()); + + // Player leaves + lobbyManager.leavePlayerFromLobby("Player1", lobbyId); + + // Mapping should still exist for rejoin + Lobby afterLeave = lobbyManager.getLobbyByUsername("Player1"); + assertEquals(lobbyId, afterLeave.getId()); + } + + @Test + void testRemoveLobbyCleanupsBothActiveAndAbsentPlayers() { + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + + lobbyManager.addPlayerToLobby("P1", lobbyId); + lobbyManager.addPlayerToLobby("P2", lobbyId); + lobbyManager.leavePlayerFromLobby("P1", lobbyId); + + // Verify both are in lobby + Lobby lobby = lobbyManager.getLobby(lobbyId); + assertTrue(lobby.getPlayerNames().contains("P2")); + assertTrue(lobby.getAbsentPlayers().contains("P1")); + + // Remove lobby + lobbyManager.removeLobby(lobbyId); + + // Both players should be removed from playerToLobby + assertNull(lobbyManager.getLobbyByUsername("P1")); + assertNull(lobbyManager.getLobbyByUsername("P2")); + } + + @Test + void testLeaveNonExistentPlayerFromLobby() { + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + + // Try to remove a player that was never added + boolean left = lobbyManager.leavePlayerFromLobby("NonExistent", lobbyId); + assertFalse(left); + } + + @Test + void testLeavePlayerFromNonExistentLobby() { + LobbyId nonExistentId = LobbyId.of(999); + + // Try to remove from non-existent lobby + boolean left = lobbyManager.leavePlayerFromLobby("Player1", nonExistentId); + assertFalse(left); + } + + @Test + void testGameStartedWithAbsentPlayerTracking() { + // This test verifies that when a game starts with 4 players, + // the absent players tracking is initialized and works + LobbyId lobbyId = lobbyManager.createNewLobby("Test Lobby"); + + lobbyManager.addPlayerToLobby("P1", lobbyId); + lobbyManager.addPlayerToLobby("P2", lobbyId); + lobbyManager.addPlayerToLobby("P3", lobbyId); + // Game should start when 4th player joins + lobbyManager.addPlayerToLobby("P4", lobbyId); + + Lobby lobby = lobbyManager.getLobby(lobbyId); + assertNotNull(lobby.getGameController(), "Game should be started"); + assertEquals(4, lobby.getPlayerNames().size()); + assertEquals(0, lobby.getAbsentPlayers().size()); + + // P1 leaves during game + boolean left = lobbyManager.leavePlayerFromLobby("P1", lobbyId); + assertTrue(left); + + assertEquals(3, lobby.getPlayerNames().size()); + assertEquals(1, lobby.getAbsentPlayers().size()); + } +} diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyTest.java new file mode 100644 index 0000000..f53475c --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyTest.java @@ -0,0 +1,195 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +import static org.junit.jupiter.api.Assertions.*; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.GameEngine; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.RoundManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.TurnManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleEngine; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; +import java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for Lobby absent players tracking functionality. */ +public class LobbyTest { + private Lobby lobby; + + @BeforeEach + void setUp() { + lobby = new Lobby(LobbyId.of(1), "Test Lobby"); + } + + @Test + void testAddPlayerSuccessfully() { + boolean added = lobby.addPlayer("Player1", 4); + assertTrue(added); + assertEquals(1, lobby.getPlayerNames().size()); + assertTrue(lobby.getPlayerNames().contains("Player1")); + } + + @Test + void testAddPlayerToFullLobby() { + lobby.addPlayer("Player1", 4); + lobby.addPlayer("Player2", 4); + lobby.addPlayer("Player3", 4); + lobby.addPlayer("Player4", 4); + + // Try to add 5th player to a 4-player lobby + boolean added = lobby.addPlayer("Player5", 4); + assertFalse(added); + } + + @Test + void testLeavePlayerMovesToAbsent() { + lobby.addPlayer("Player1", 4); + assertTrue(lobby.getPlayerNames().contains("Player1")); + assertTrue(lobby.getAbsentPlayers().isEmpty()); + + // Player leaves + boolean left = lobby.leavePlayer("Player1"); + assertTrue(left); + + // Player should be in absent set + assertFalse(lobby.getPlayerNames().contains("Player1")); + assertTrue(lobby.getAbsentPlayers().contains("Player1")); + } + + @Test + void testRestoreAbsentPlayerSuccessfully() { + lobby.addPlayer("Player1", 4); + lobby.leavePlayer("Player1"); + + // Restore the absent player + boolean restored = lobby.restoreAbsentPlayer("Player1"); + assertTrue(restored); + + // Player should be back in active list + assertTrue(lobby.getPlayerNames().contains("Player1")); + assertFalse(lobby.getAbsentPlayers().contains("Player1")); + } + + @Test + void testRestoreAbsentPlayerCannotRestoreTwice() { + lobby.addPlayer("Player1", 4); + lobby.leavePlayer("Player1"); + + boolean restored1 = lobby.restoreAbsentPlayer("Player1"); + assertTrue(restored1); + + // Try to restore again - should fail + boolean restored2 = lobby.restoreAbsentPlayer("Player1"); + assertFalse(restored2); + } + + @Test + void testHasAnyPlayersWithActiveOnly() { + lobby.addPlayer("Player1", 4); + assertTrue(lobby.hasAnyPlayers()); + } + + @Test + void testHasAnyPlayersWithAbsentOnly() { + lobby.addPlayer("Player1", 4); + lobby.leavePlayer("Player1"); + assertTrue(lobby.hasAnyPlayers()); + } + + @Test + void testHasAnyPlayersEmpty() { + assertFalse(lobby.hasAnyPlayers()); + } + + @Test + void testIsPlayerActive() { + lobby.addPlayer("Player1", 4); + assertTrue(lobby.isPlayerActive("Player1")); + + lobby.leavePlayer("Player1"); + assertFalse(lobby.isPlayerActive("Player1")); + + lobby.restoreAbsentPlayer("Player1"); + assertTrue(lobby.isPlayerActive("Player1")); + } + + @Test + void testMultiplePlayersLeaveAndRejoin() { + // Add 4 players + lobby.addPlayer("P1", 4); + lobby.addPlayer("P2", 4); + lobby.addPlayer("P3", 4); + lobby.addPlayer("P4", 4); + + // All active + assertEquals(4, lobby.getPlayerNames().size()); + assertEquals(0, lobby.getAbsentPlayers().size()); + + // P1 and P2 leave + lobby.leavePlayer("P1"); + lobby.leavePlayer("P2"); + + assertEquals(2, lobby.getPlayerNames().size()); + assertEquals(2, lobby.getAbsentPlayers().size()); + + // P1 comes back + lobby.restoreAbsentPlayer("P1"); + + assertEquals(3, lobby.getPlayerNames().size()); + assertEquals(1, lobby.getAbsentPlayers().size()); + assertTrue(lobby.getPlayerNames().contains("P1")); + assertTrue(lobby.getAbsentPlayers().contains("P2")); + } + + @Test + void testGameEndedCallbackInvoked() { + // Create a simple game setup + GameState state = new GameState(); + GameEngine engine = + new GameEngine( + state, + new RuleEngine(new ArrayList<>()), + new RoundManager(), + new TurnManager()); + GameController gameController = new GameController(engine); + + // Track if callback was invoked + boolean[] callbackInvoked = {false}; + + // Set callback + lobby.setOnGameEndedCallback(() -> callbackInvoked[0] = true); + + // Simulate game ending + gameController.endGame(); + // Callback should NOT be invoked yet because it's not registered in the + // controller + + // Now register the callback in the controller + gameController.setOnGameEndedCallback(() -> callbackInvoked[0] = true); + gameController.endGame(); + + assertTrue(callbackInvoked[0], "Game ended callback should have been invoked"); + } + + @Test + void testRenamePlayerInActiveList() { + lobby.addPlayer("OldName", 4); + assertTrue(lobby.getPlayerNames().contains("OldName")); + + boolean renamed = lobby.renamePlayer("OldName", "NewName"); + assertTrue(renamed); + + assertFalse(lobby.getPlayerNames().contains("OldName")); + assertTrue(lobby.getPlayerNames().contains("NewName")); + } + + @Test + void testRenamePlayerCannotRenameIfAbsent() { + lobby.addPlayer("Player1", 4); + lobby.leavePlayer("Player1"); + + // Should NOT be able to rename an absent player directly + boolean renamed = lobby.renamePlayer("Player1", "Player2"); + assertFalse(renamed); + } +}