diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index 340dc8e..329e669 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -453,4 +453,64 @@ GET_NEXT_MESSAGE +OK TYPE=GLOBAL GAME=-1 USER=player1 TARGET=null TIME=9:30 TEXT="Guten Tag" END +``` + +### Example Response (success) + +``` ++OK +END +``` + +## JOIN_LOBBY command + +The `JOIN_LOBBY` command requests the server to add the currently logged-in user to the lobby with the given identifier. The server resolves the username from the session; clients must only provide the `ID` parameter. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `ID` | `int` | no | Numeric id of the target lobby | + +### Implementation notes + +- Parser: `JoinLobbyParser` — reads the `ID` parameter and builds `JoinLobbyRequest`. +- Handler: `JoinLobbyHandler` — resolves the username from `UserRegistry` using the session id, attempts to add the user to the lobby via `LobbyManager.addPlayerToLobby(...)` and returns appropriate responses. + +### Success Response + +No additional response fields. The server replies with a simple `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `USER_NOT_LOGGED_IN` | No user associated with the session (pre-execution check failed) | +| `LOBBY_NOT_FOUND` | The specified lobby id does not exist | +| `LOBBY_FULL_OR_ALREADY_IN` | Lobby is full or the user is already in the lobby | + +### Example Request + +``` +JOIN_LOBBY ID=1 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=LOBBY_NOT_FOUND + MESSAGE=Lobby not found +END ``` \ No newline at end of file diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index 7062252..881705a 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -24,6 +24,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingRequest; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageHandler; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageParser; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager; @@ -85,7 +86,8 @@ public class ServerApp { SESSION_DISCONNECT_JOB_PERIOD, TimeUnit.SECONDS); - registerCommands(dispatcher, router, responseDispatcher, userRegistry); + LobbyManager lobbyManager = new LobbyManager(); + registerCommands(dispatcher, router, responseDispatcher, userRegistry, lobbyManager); NetworkManager networkManager = new NetworkManager(port, sessionManager, router); networkManager.start(); @@ -102,7 +104,8 @@ public class ServerApp { CommandParserDispatcher parserDispatcher, CommandRouter commandRouter, ResponseDispatcher responseDispatcher, - UserRegistry userRegistry) { + UserRegistry userRegistry, + LobbyManager lobbyManager) { parserDispatcher.register("PING", new PingParser()); commandRouter.register(PingRequest.class, new PingHandler(responseDispatcher)); @@ -136,5 +139,19 @@ public class ServerApp { parserDispatcher.register("LIST_USERS", new ListUsersParser()); commandRouter.register( ListUsersRequest.class, new ListUsersHandler(responseDispatcher, userRegistry)); + + // JOIN_LOBBY registration + parserDispatcher.register( + "JOIN_LOBBY", + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby + .JoinLobbyParser()); + commandRouter.register( + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby + .JoinLobbyRequest.class, + (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby + .JoinLobbyRequest>) + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby + .JoinLobbyHandler(responseDispatcher, lobbyManager, userRegistry)); } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerHandler.java new file mode 100644 index 0000000..25a93c8 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerHandler.java @@ -0,0 +1,33 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.add_player; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import java.util.Optional; + +/** Handler for the ADD_PLAYER command. */ +public class AddPlayerHandler extends CommandHandler { + private final UserRegistry userRegistry; + + public AddPlayerHandler(UserRegistry userRegistry, ResponseDispatcher responseDispatcher) { + super(responseDispatcher); + this.userRegistry = userRegistry; + } + + @Override + public void execute(AddPlayerRequest request) { + Optional created = + userRegistry.registerIfAvailable( + request.getName(), request.getContext().sessionId()); + if (created.isEmpty()) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "NAME_TAKEN", "Name already taken")); + return; + } + + responseDispatcher.dispatch(new OkResponse(request.getContext())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerParser.java new file mode 100644 index 0000000..6e869e6 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerParser.java @@ -0,0 +1,20 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.add_player; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor; + +/** Parser for the ADD_PLAYER command. */ +public class AddPlayerParser implements CommandParser { + @Override + public AddPlayerRequest parse(PrimitiveRequest primitiveRequest) { + RequestParameterAccessor accessor = + new RequestParameterAccessor(primitiveRequest.parameters()); + + String name = accessor.require("NAME"); + + int chips = accessor.require("CHIPS", Integer::parseInt); + + return new AddPlayerRequest(primitiveRequest.context(), name, chips); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerRequest.java new file mode 100644 index 0000000..2e55ee7 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/add_player/AddPlayerRequest.java @@ -0,0 +1,24 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.add_player; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +/** Request data for the ADD_PLAYER command. */ +public class AddPlayerRequest extends Request { + private final String name; + private final int chips; + + public AddPlayerRequest(RequestContext context, String name, int chips) { + super(context); + this.name = name; + this.chips = chips; + } + + public String getName() { + return name; + } + + public int getChips() { + return chips; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyHandler.java new file mode 100644 index 0000000..579c281 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyHandler.java @@ -0,0 +1,83 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; + +/** + * Handler for the `JOIN_LOBBY` command. + * + *

Resolves the username from the session (requires {@link UserLoggedInCheck}), looks up the + * target lobby and attempts to add the user. On success an `+OK` response is dispatched; on failure + * an appropriate {@link ErrorResponse} with one of the error codes `LOBBY_NOT_FOUND`, + * `USER_NOT_LOGGED_IN` or `LOBBY_FULL_OR_ALREADY_IN` is returned. + */ +public class JoinLobbyHandler extends CommandHandler { + private final LobbyManager lobbyManager; + private final UserRegistry userRegistry; + + /** + * Create a new {@link JoinLobbyHandler}. + * + * @param responseDispatcher dispatcher used to send responses back to the client + * @param lobbyManager manager providing lobby state and operations + * @param userRegistry registry to resolve session -> user mappings + */ + public JoinLobbyHandler( + ResponseDispatcher responseDispatcher, + LobbyManager lobbyManager, + UserRegistry userRegistry) { + super(responseDispatcher); + this.lobbyManager = lobbyManager; + this.userRegistry = userRegistry; + addCheck(new UserLoggedInCheck(userRegistry)); + } + + /** + * Execute the join-lobby request. + * + * @param request the parsed {@link JoinLobbyRequest} + */ + @Override + public void execute(JoinLobbyRequest request) { + LobbyId lid = LobbyId.of(request.getId()); + var lobby = lobbyManager.getLobby(lid); + if (lobby == null) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found")); + return; + } + + var maybeUser = userRegistry.getBySessionId(request.getContext().sessionId()); + if (maybeUser.isEmpty()) { + // UserLoggedInCheck should normally prevent this; keep safe fallback + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), + "USER_NOT_LOGGED_IN", + "No user associated with session")); + return; + } + + User user = maybeUser.get(); + String username = user.getName(); + + boolean ok = lobbyManager.addPlayerToLobby(username, lid); + if (!ok) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), + "LOBBY_FULL_OR_ALREADY_IN", + "Lobby full or user already in lobby")); + return; + } + + responseDispatcher.dispatch(new OkResponse(request.getContext())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyParser.java new file mode 100644 index 0000000..6f7b892 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyParser.java @@ -0,0 +1,33 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor; + +/** + * Parser for the `JOIN_LOBBY` command. + * + *

Expected request parameters: + * + *

    + *
  • `ID` (int) — numeric lobby identifier (required) + *
+ * + *

The parser builds a {@link JoinLobbyRequest} containing the parsed lobby id and the original + * request context. + */ +public class JoinLobbyParser implements CommandParser { + /** + * Parse the incoming primitive request into a {@link JoinLobbyRequest}. + * + * @param primitiveRequest the raw primitive request + * @return a {@link JoinLobbyRequest} with the parsed lobby id and context + */ + @Override + public JoinLobbyRequest parse(PrimitiveRequest primitiveRequest) { + RequestParameterAccessor accessor = + new RequestParameterAccessor(primitiveRequest.parameters()); + int id = accessor.require("ID", Integer::parseInt); + return new JoinLobbyRequest(primitiveRequest.context(), id); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyRequest.java new file mode 100644 index 0000000..4402707 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/join_lobby/JoinLobbyRequest.java @@ -0,0 +1,34 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +/** + * Request data for the `JOIN_LOBBY` command. + * + *

Contains the lobby id the client wants to join and inherits the {@link Request} contextual + * information. + */ +public class JoinLobbyRequest extends Request { + private final int id; + + /** + * Create a new {@link JoinLobbyRequest}. + * + * @param context the request context + * @param id numeric lobby id to join + */ + public JoinLobbyRequest(RequestContext context, int id) { + super(context); + this.id = id; + } + + /** + * Returns the lobby id requested by the client. + * + * @return numeric lobby id + */ + public int getId() { + return id; + } +} 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 new file mode 100644 index 0000000..7653685 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/Lobby.java @@ -0,0 +1,131 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +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.player.PlayerId; +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.concurrent.CopyOnWriteArrayList; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Represents a single lobby: id, name, players and an optional GameController. */ +public class Lobby { + private static final Logger LOGGER = Logger.getLogger(Lobby.class.getName()); + + public enum AddResult { + NOT_ADDED, + ADDED, + ADDED_AND_STARTED + } + + private final LobbyId id; + private final String name; + private final List playerNames = new CopyOnWriteArrayList<>(); + private volatile GameController gameController; + + public Lobby(LobbyId id, String name) { + this.id = id; + this.name = name; + } + + public LobbyId getId() { + return id; + } + + public String getName() { + return name; + } + + public List getPlayerNames() { + return List.copyOf(playerNames); + } + + /** + * Try to add a player to this lobby. + * + * @return true if added, false if already present or full + */ + public boolean addPlayer(String playerName, int maxPlayers) { + if (playerName == null) { + return false; + } + synchronized (playerNames) { + if (playerNames.contains(playerName)) { + return false; + } + if (playerNames.size() >= maxPlayers) { + return false; + } + playerNames.add(playerName); + return true; + } + } + + public boolean removePlayer(String playerName) { + return playerNames.remove(playerName); + } + + public void initGame(GameController controller) { + this.gameController = controller; + } + + public GameController getGameController() { + return gameController; + } + + /** + * 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 + * avoid races when multiple joins happen concurrently. + * + * @return {@link AddResult} indicating whether the player was added and if a game was started + */ + public AddResult addPlayerAndMaybeStart( + String playerName, int maxPlayers, int autoStartPlayers, int defaultStartChips) { + if (playerName == null) { + return AddResult.NOT_ADDED; + } + synchronized (playerNames) { + if (playerNames.contains(playerName)) { + return AddResult.NOT_ADDED; + } + if (playerNames.size() >= maxPlayers) { + return AddResult.NOT_ADDED; + } + playerNames.add(playerName); + + // If threshold reached and no game running, create and start game here + if (gameController == null && playerNames.size() == autoStartPlayers) { + try { + GameState state = new GameState(); + GameEngine engine = + new GameEngine( + state, + new RuleEngine(new ArrayList<>()), + new RoundManager(), + new TurnManager()); + GameController game = new GameController(engine); + + for (String p : playerNames) { + game.addPlayer(PlayerId.of(p), defaultStartChips); + } + + game.startGame(); + this.gameController = game; + LOGGER.info(() -> "Auto-started game in lobby " + id.value()); + return AddResult.ADDED_AND_STARTED; + } catch (RuntimeException e) { + LOGGER.log(Level.WARNING, "Auto-start failed for lobby " + id.value(), e); + return AddResult.ADDED; + } + } + + return AddResult.ADDED; + } + } +} 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 new file mode 100644 index 0000000..d6fee3a --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyEventListener.java @@ -0,0 +1,7 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +/** Listener interface for lobby lifecycle events. */ +public interface LobbyEventListener { + /** Called when a game is automatically started in the given lobby. */ + void onGameStarted(LobbyId lobbyId); +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyId.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyId.java new file mode 100644 index 0000000..457aea0 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyId.java @@ -0,0 +1,8 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +/** Typesafe wrapper for lobby IDs (1-8). */ +public record LobbyId(int value) { + public static LobbyId of(int value) { + return new LobbyId(value); + } +} 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 new file mode 100644 index 0000000..2179e8a --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyManager.java @@ -0,0 +1,144 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby.AddResult; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; + +/** Manages dynamic creation of up to 8 lobbies and maps players to lobbies. */ +public class LobbyManager { + private static final int MAX_LOBBIES = 8; + private static final int DEFAULT_MAX_PLAYERS = 4; + private static final int AUTO_START_PLAYERS = 4; + private static final int DEFAULT_START_CHIPS = 20000; + + private final Map activeLobbies = new ConcurrentHashMap<>(); + private final Map playerToLobby = new ConcurrentHashMap<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + private final int maxPlayersPerLobby; + private static final Logger LOGGER = Logger.getLogger(LobbyManager.class.getName()); + + public LobbyManager() { + this(DEFAULT_MAX_PLAYERS); + } + + public LobbyManager(int maxPlayersPerLobby) { + this.maxPlayersPerLobby = maxPlayersPerLobby; + } + + /** Create a new lobby with an automatic id (1..8). Returns null if none available. */ + public synchronized LobbyId createNewLobby(String name) { + if (activeLobbies.size() >= MAX_LOBBIES) { + return null; + } + for (int i = 1; i <= MAX_LOBBIES; i++) { + LobbyId id = LobbyId.of(i); + if (!activeLobbies.containsKey(id)) { + Lobby lobby = new Lobby(id, name == null ? ("Room " + i) : name); + activeLobbies.put(id, lobby); + return id; + } + } + return null; + } + + public Lobby getLobby(LobbyId id) { + if (id == null) { + return null; + } + return activeLobbies.get(id); + } + + public Lobby getLobbyByUsername(String username) { + LobbyId id = playerToLobby.get(username); + if (id == null) { + return null; + } + return activeLobbies.get(id); + } + + public boolean addPlayerToLobby(String username, LobbyId lobbyId) { + Lobby lobby = getLobby(lobbyId); + if (lobby == null) { + return false; + } + AddResult result = + lobby.addPlayerAndMaybeStart( + username, maxPlayersPerLobby, AUTO_START_PLAYERS, DEFAULT_START_CHIPS); + if (result != AddResult.NOT_ADDED) { + playerToLobby.put(username, lobbyId); + if (result == AddResult.ADDED_AND_STARTED) { + LOGGER.info( + () -> + "Lobby " + + lobbyId.value() + + " reached " + + AUTO_START_PLAYERS + + " players; game started."); + notifyGameStarted(lobbyId); + } + return true; + } + return false; + } + + public void addListener(LobbyEventListener listener) { + listeners.add(listener); + } + + public void removeListener(LobbyEventListener listener) { + listeners.remove(listener); + } + + private void notifyGameStarted(LobbyId lobbyId) { + for (LobbyEventListener l : listeners) { + try { + l.onGameStarted(lobbyId); + } catch (RuntimeException e) { + LOGGER.warning( + () -> + "Listener threw while handling game-start for lobby " + + lobbyId.value()); + } + } + } + + public boolean removePlayer(String username) { + LobbyId id = playerToLobby.remove(username); + if (id == null) { + return false; + } + Lobby lobby = activeLobbies.get(id); + if (lobby == null) { + return false; + } + boolean removed = lobby.removePlayer(username); + // If lobby becomes empty, remove it + if (lobby.getPlayerNames().isEmpty()) { + activeLobbies.remove(id); + } + return removed; + } + + public Collection getAllLobbies() { + return activeLobbies.values(); + } + + /** + * Apply the given action to every player username in the lobby identified by {@code lobbyId}. + * This is a small helper that keeps iteration logic centralized and avoids leaking internal + * collections to callers. + */ + public void broadcast(LobbyId lobbyId, java.util.function.Consumer action) { + Lobby lobby = getLobby(lobbyId); + if (lobby == null) { + return; + } + for (String username : lobby.getPlayerNames()) { + action.accept(username); + } + } +}