Merge branch 'main' into 'feat/143-leave-lobby-command'

This commit is contained in:
Jona Walpert
2026-05-13 23:12:44 +02:00
6 changed files with 543 additions and 0 deletions
@@ -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
}
}
}
}
@@ -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<String> playerNames = new CopyOnWriteArrayList<>();
private final Set<String> 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<String> 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());
@@ -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);
}
@@ -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<Lobby> 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);
}
}
/**
@@ -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());
}
}
@@ -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);
}
}