diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java index 86b4089..fb4eb95 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java @@ -24,8 +24,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; /** - * The ClientService class is responsible for managing the connection to the server, sending - * commands, and receiving responses. It uses a TcpTransport to communicate with the server and an + * The ClientService class is responsible for managing the connection to the + * server, sending + * commands, and receiving responses. It uses a TcpTransport to communicate with + * the server and an * ExecutorService to handle asynchronous requests. */ public class ClientService { @@ -39,20 +41,20 @@ public class ClientService { private final AtomicInteger idGenerator; private Logger logger; - private final Map> pendingResponses = - new ConcurrentHashMap<>(); - private final CopyOnWriteArrayList>> eventListeners = - new CopyOnWriteArrayList<>(); + private final Map> pendingResponses = new ConcurrentHashMap<>(); + private final CopyOnWriteArrayList>> eventListeners = new CopyOnWriteArrayList<>(); private Thread readerThread = null; private final AtomicBoolean running = new AtomicBoolean(false); private static final int READER_JOIN_TIMEOUT_MS = 500; /** - * Constructs a ClientService with the given server IP and port. It establishes a socket - * connection to the server and initializes the TcpTransport and ExecutorService for + * Constructs a ClientService with the given server IP and port. It establishes + * a socket + * connection to the server and initializes the TcpTransport and ExecutorService + * for * communication. * - * @param ip The IP address of the server to connect to. + * @param ip The IP address of the server to connect to. * @param port The port number of the server to connect to. */ public ClientService(String ip, int port) { @@ -77,25 +79,24 @@ public class ClientService { private void startReaderThread() { running.set(true); - readerThread = - new Thread( - () -> { - while (running.get()) { - try { - RawPacket rp = clienttcptransport.read(); - processRawPacket(rp); - } catch (IOException e) { - if (running.get()) { - logger.warn("IO error on transport reader", e); - } - break; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } + readerThread = new Thread( + () -> { + while (running.get()) { + try { + RawPacket rp = clienttcptransport.read(); + processRawPacket(rp); + } catch (IOException e) { + if (running.get()) { + logger.warn("IO error on transport reader", e); } - }, - "casono-client-reader"); + break; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }, + "casono-client-reader"); readerThread.setDaemon(true); readerThread.start(); } @@ -111,13 +112,14 @@ public class ClientService { for (String rawLine : responseText.split("\\n")) { String line = rawLine; + String trimmed = line.trim(); if (!hasStatus) { - if ("+OK".equals(line)) { + if ("+OK".equals(trimmed)) { success = true; hasStatus = true; continue; } - if (line.startsWith("-ERR") || line.startsWith("-ERROR")) { + if (trimmed.startsWith("-ERR") || trimmed.startsWith("-ERROR")) { success = false; hasStatus = true; continue; @@ -125,13 +127,12 @@ public class ClientService { continue; } - if ("END".equals(line)) { + if ("END".equals(trimmed)) { break; } - if (line.startsWith("\t")) { - line = line.substring(1); - } + // remove leading indentation (tabs/spaces) so parameters match regex parsing + line = line.replaceFirst("^\\s+", ""); lines.add(line); } @@ -152,6 +153,7 @@ public class ClientService { if (rid == 0) { for (Consumer> l : eventListeners) { try { + logger.debug("Parsed response lines: {}", lines); l.accept(List.copyOf(lines)); } catch (Exception e) { logger.warn("Event listener threw", e); @@ -168,7 +170,8 @@ public class ClientService { } /** - * Constructs a ClientService in offline mode. No network connection will be attempted and calls + * Constructs a ClientService in offline mode. No network connection will be + * attempted and calls * to processCommand will throw a RuntimeException. * * @param offline true to create an offline (no-network) client service @@ -181,19 +184,21 @@ public class ClientService { this.executor = Executors.newSingleThreadExecutor(); } - /** Returns true if this ClientService is running in offline mode (no network). */ + /** + * Returns true if this ClientService is running in offline mode (no network). + */ public boolean isOffline() { return offlineMode; } // Allow primitive values to contain hyphens (UUIDs) in addition to // digits/words/colons - static Pattern responseRex = - Pattern.compile( - "(?\\w+)=(('(?([^']|\\')+)')|(?[+-]?[-\\d\\w:]+))"); + static Pattern responseRex = Pattern.compile( + "(?\\w+)=(('(?([^']|\\')+)')|(?[+-]?[-\\d\\w:]+))"); /** - * Removes escape characters from a string, specifically converting escaped single quotes (\') + * Removes escape characters from a string, specifically converting escaped + * single quotes (\') * back to regular single quotes ('). * * @param input The escaped string to process. @@ -204,8 +209,10 @@ public class ClientService { } /** - * Converts a list of raw string parameters into a list of {@link RequestParameter} objects. It - * uses a regex matcher to distinguish between quoted strings (which are unescaped) and + * Converts a list of raw string parameters into a list of + * {@link RequestParameter} objects. It + * uses a regex matcher to distinguish between quoted strings (which are + * unescaped) and * primitive values. * * @param input A list of raw strings to be parsed. @@ -230,10 +237,12 @@ public class ClientService { .toList(); } - private static record ParsedResponse(boolean success, List lines) {} + private static record ParsedResponse(boolean success, List lines) { + } /** - * Register an event listener that receives unsolicited event payload lines (no status prefix). + * Register an event listener that receives unsolicited event payload lines (no + * status prefix). */ public void addEventListener(Consumer> listener) { eventListeners.add(listener); @@ -244,14 +253,18 @@ public class ClientService { } /** - * Sends a command to the server and processes the multi-line response. It handles the protocol - * handshake (expecting +OK), strips leading tabs from response lines, and collects them until + * Sends a command to the server and processes the multi-line response. It + * handles the protocol + * handshake (expecting +OK), strips leading tabs from response lines, and + * collects them until * the "END" marker is reached. * * @param message The raw command string to be sent to the transport layer. - * @return A list of response lines received from the server (excluding protocol markers). - * @throws RuntimeException if the server responds with an error or if a communication failure - * occurs. + * @return A list of response lines received from the server (excluding protocol + * markers). + * @throws RuntimeException if the server responds with an error or if a + * communication failure + * occurs. */ protected List processCommand(String message) { if (offlineMode) { @@ -262,15 +275,14 @@ public class ClientService { ArrayBlockingQueue q = new ArrayBlockingQueue<>(1); pendingResponses.put(reqId, q); - Future writeFuture = - executor.submit( - () -> { - try { - clienttcptransport.write(new RawPacket(reqId, message)); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + Future writeFuture = executor.submit( + () -> { + try { + clienttcptransport.write(new RawPacket(reqId, message)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); try { writeFuture.get(); @@ -302,11 +314,15 @@ public class ClientService { } /** - * Helper method to send a request to the server using the ExecutorService. It submits the - * request as a Runnable task and waits for its completion. If the task is interrupted or - * encounters an execution exception, it throws a RuntimeException with the appropriate cause. + * Helper method to send a request to the server using the ExecutorService. It + * submits the + * request as a Runnable task and waits for its completion. If the task is + * interrupted or + * encounters an execution exception, it throws a RuntimeException with the + * appropriate cause. * - * @param request The Runnable task representing the request to be sent to the server. + * @param request The Runnable task representing the request to be sent to the + * server. */ private void sendRequest(Runnable request) { Future future = executor.submit(request); @@ -320,9 +336,12 @@ public class ClientService { } /** - * Helper method to extract the cause of an exception and return it as a RuntimeException. If - * the cause is null, it returns the original exception as a RuntimeException. If the cause is - * already a RuntimeException, it returns it directly. Otherwise, it wraps the cause in a new + * Helper method to extract the cause of an exception and return it as a + * RuntimeException. If + * the cause is null, it returns the original exception as a RuntimeException. + * If the cause is + * already a RuntimeException, it returns it directly. Otherwise, it wraps the + * cause in a new * RuntimeException and returns it. * * @param e The exception from which to extract the cause. @@ -340,8 +359,10 @@ public class ClientService { } /** - * Closes the socket connection to the server and shuts down the ExecutorService. It also closes - * the TcpTransport used for communication. If any IOException occurs during this process, it + * Closes the socket connection to the server and shuts down the + * ExecutorService. It also closes + * the TcpTransport used for communication. If any IOException occurs during + * this process, it * prints the exception to the console. */ public void closeSocket() { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java index 397d723..05fb146 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java @@ -85,6 +85,8 @@ public class LobbyButtonGridManager { } catch (NumberFormatException ex) { return; } + LOGGER.info("LOBBY_CLOSED event for lobby {} — mapping before: {}", lid, + translationManager.getButtonIdToLobbyId()); // find button id(s) for this lobby and remove mapping Map mapping = translationManager.getButtonIdToLobbyId(); Integer toRemove = null; @@ -95,9 +97,46 @@ public class LobbyButtonGridManager { } } if (toRemove != null) { + LOGGER.info("Removing mapping for button {} -> lobby {} (LOBBY_CLOSED event)", toRemove, + lid); translationManager.removeLobbyButton(toRemove); + LOGGER.debug("Mapping after removal: {}", translationManager.getButtonIdToLobbyId()); javafx.application.Platform.runLater(this::renderLobbyButtons); } + } else if (evt != null && "LOBBY_CREATED".equalsIgnoreCase(evt) && lidStr != null) { + int lid; + try { + lid = Integer.parseInt(lidStr); + } catch (NumberFormatException ex) { + return; + } + + LOGGER.info("LOBBY_CREATED event for lobby {} — mapping before: {}", lid, + translationManager.getButtonIdToLobbyId()); + + Map mapping = translationManager.getButtonIdToLobbyId(); + + // If this lobby is already known, nothing to do + if (mapping.containsValue(lid)) { + LOGGER.debug("Lobby {} already mapped, skipping", lid); + return; + } + + // find next free button id (1..8) + for (int candidate = 1; candidate <= 8; candidate++) { + if (!mapping.containsKey(candidate)) { + try { + translationManager.addLobbyButton(candidate, lid); + LOGGER.info("Mapped new lobby {} to button {}", lid, candidate); + LOGGER.debug("Mapping after add: {}", translationManager.getButtonIdToLobbyId()); + javafx.application.Platform.runLater(this::renderLobbyButtons); + break; + } catch (Exception ex) { + // grid full? try next candidate + LOGGER.debug("Could not map created lobby {}: {}", lid, ex.getMessage()); + } + } + } } }); } @@ -110,8 +149,29 @@ public class LobbyButtonGridManager { private void refreshMappings() { Map mapping = translationManager.getButtonIdToLobbyId(); + // Always attempt to sync with server-side lobby list so clients that start + // after a lobby was created will discover it. This keeps the local mapping + // in sync with the server without requiring a restart. + List serverLobbies = null; + java.util.Set serverIds = new java.util.HashSet<>(); + try { + serverLobbies = lobbyClient.getLobbyList(); + if (serverLobbies != null) { + for (var li : serverLobbies) { + serverIds.add(li.id); + } + } + reconcileWithServerLobbies(serverLobbies); + } catch (Exception ex) { + LOGGER.debug("Could not fetch lobby list: {}", ex.getMessage()); + } + if (mapping.isEmpty()) { - return; + // mapping may still be empty after reconciliation + if (translationManager.getButtonIdToLobbyId().isEmpty()) { + return; + } + mapping = translationManager.getButtonIdToLobbyId(); } List> entries = new ArrayList<>(mapping.entrySet()); @@ -125,26 +185,91 @@ public class LobbyButtonGridManager { int lobbyId = lobbyIdObj.intValue(); CompletableFuture.supplyAsync( - () -> { - try { - return lobbyClient.fetchLobbyStatusString(lobbyId); - } catch (Exception ex) { - LOGGER.info("Lobby {} missing: {}", lobbyId, ex.getMessage()); - return null; - } - }, - executor) + () -> { + try { + return lobbyClient.fetchLobbyStatusString(lobbyId); + } catch (Exception ex) { + LOGGER.info("Lobby {} missing: {}", lobbyId, ex.getMessage()); + return null; + } + }, + executor) .thenAccept( status -> { + // Do not remove a mapping just because the status fetch + // temporarily returned null. Only remove mappings when + // the server no longer lists the lobby (reconcileWithServerLobbies + // handles removals based on authoritative server list). if (status == null) { - translationManager.removeLobbyButton(buttonId); - - javafx.application.Platform.runLater(this::renderLobbyButtons); + if (!serverIds.contains(lobbyId)) { + translationManager.removeLobbyButton(buttonId); + javafx.application.Platform.runLater(this::renderLobbyButtons); + } else { + // treat as created/running later; keep mapping + LOGGER.debug("Temporary missing status for lobby {} - keeping mapping", + lobbyId); + } } }); } } + private void reconcileWithServerLobbies(List serverLobbies) { + if (serverLobbies == null) { + return; + } + + Map mapping = translationManager.getButtonIdToLobbyId(); + + // Build set of lobby ids returned by server + java.util.Set serverIds = new java.util.HashSet<>(); + for (var li : serverLobbies) { + serverIds.add(li.id); + } + + LOGGER.debug("Reconciling mappings. serverIds={} mapping={}", serverIds, mapping); + + // Remove mappings for lobbies that no longer exist + java.util.List toRemove = new java.util.ArrayList<>(); + for (var e : mapping.entrySet()) { + Integer lid = e.getValue(); + if (lid == null) { + continue; + } + if (!serverIds.contains(lid)) { + toRemove.add(e.getKey()); + } + } + for (Integer bid : toRemove) { + LOGGER.info("Reconciling: removing mapping for button {} (server no longer lists lobby)", bid); + translationManager.removeLobbyButton(bid); + } + + // Add server lobbies that are not yet mapped + for (var li : serverLobbies) { + if (mapping.containsValue(li.id)) { + continue; + } + // find next free button id (1..8) + for (int candidate = 1; candidate <= 8; candidate++) { + if (!mapping.containsKey(candidate)) { + try { + translationManager.addLobbyButton(candidate, li.id); + LOGGER.info("Reconciling: added mapping button {} -> lobby {}", candidate, li.id); + break; + } catch (Exception ex) { + // grid full? try next candidate + LOGGER.debug("Could not add mapping for lobby {}: {}", li.id, ex.getMessage()); + } + } + } + } + + if (!toRemove.isEmpty() || !serverLobbies.isEmpty()) { + javafx.application.Platform.runLater(this::renderLobbyButtons); + } + } + public void renderLobbyButtons() { gridPane.getChildren().clear(); @@ -215,10 +340,8 @@ public class LobbyButtonGridManager { statusStr -> { LobbyStatus status = parseLobbyStatus(statusStr); - String path = - getImagePathForButton( - buttonId, - status == null ? LobbyStatus.CREATED : status); + String path = getImagePathForLobby( + lobbyId, status == null ? LobbyStatus.CREATED : status); Image img = safeLoadImage(path); @@ -253,11 +376,11 @@ public class LobbyButtonGridManager { } } - private String getImagePathForButton(int buttonId, LobbyStatus status) { + private String getImagePathForLobby(int lobbyId, LobbyStatus status) { String statusStr = status == LobbyStatus.CREATED ? "created" : "running"; - return String.format(BUTTON_IMAGE_TEMPLATE, buttonId, statusStr); + return String.format(BUTTON_IMAGE_TEMPLATE, lobbyId, statusStr); } private Image safeLoadImage(String path) { @@ -305,32 +428,30 @@ public class LobbyButtonGridManager { int lobbyId = lobbyIdObj.intValue(); CompletableFuture.supplyAsync( - () -> { - String statusStr = lobbyClient.fetchLobbyStatusString(lobbyId); + () -> { + String statusStr = lobbyClient.fetchLobbyStatusString(lobbyId); - LobbyStatus status = parseLobbyStatus(statusStr); + LobbyStatus status = parseLobbyStatus(statusStr); - return status == null ? LobbyStatus.CREATED : status; - }, - executor) + return status == null ? LobbyStatus.CREATED : status; + }, + executor) .thenAccept( status -> { - String path = getImagePathForButton(buttonId, status); + String path = getImagePathForLobby(lobbyId, status); javafx.application.Platform.runLater( () -> { for (Node node : gridPane.getChildren()) { boolean isButton = node instanceof Button; - boolean idMatches = - ("lobbyBtn-" + buttonId) - .equals(node.getId()); + boolean idMatches = ("lobbyBtn-" + buttonId) + .equals(node.getId()); if (isButton && idMatches) { Button btn = (Button) node; - ImageView iv = - new ImageView(safeLoadImage(path)); + ImageView iv = new ImageView(safeLoadImage(path)); iv.setPreserveRatio(true); iv.fitWidthProperty() @@ -375,8 +496,7 @@ public class LobbyButtonGridManager { javafx.application.Platform.runLater( () -> { - javafx.stage.Stage currentStage = - (javafx.stage.Stage) gridPane.getScene().getWindow(); + javafx.stage.Stage currentStage = (javafx.stage.Stage) gridPane.getScene().getWindow(); currentStage.hide(); 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 e1cf1d6..5581892 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 @@ -54,7 +54,7 @@ public class ServerApp { private static final int USER_CLEANUP_JOB_RECONNECT_THRESHOLD = 10; private static final int SESSION_DISCONNECT_JOB_DELAY = 0; private static final int SESSION_DISCONNECT_JOB_PERIOD = 2; - private static final int SESSION_DISCONNECT_JOB_TIMEOUT = 5; + private static final int SESSION_DISCONNECT_JOB_TIMEOUT = 30000; private static final int LOBBY_EXPIRY_SECONDS = 30; private static final int LOBBY_CLEANUP_INITIAL_DELAY_SECONDS = 5; private static final int LOBBY_CLEANUP_PERIOD_SECONDS = 5; @@ -94,7 +94,7 @@ public class ServerApp { TimeUnit.SECONDS); LobbyManager lobbyManager = new LobbyManager(); - registerCommands(dispatcher, router, responseDispatcher, userRegistry, lobbyManager); + registerCommands(dispatcher, router, responseDispatcher, userRegistry, lobbyManager, sessionManager); // Periodic cleanup: remove empty lobbies older than 30s and notify affected // users @@ -147,7 +147,8 @@ public class ServerApp { CommandRouter commandRouter, ResponseDispatcher responseDispatcher, UserRegistry userRegistry, - LobbyManager lobbyManager) { + LobbyManager lobbyManager, + SessionManager sessionManager) { parserDispatcher.register("PING", new PingParser()); commandRouter.register(PingRequest.class, new PingHandler(responseDispatcher)); @@ -292,8 +293,8 @@ public class ServerApp { (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby .create_lobby.CreateLobbyRequest>) - new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby - .CreateLobbyHandler(responseDispatcher, lobbyManager)); + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby + .CreateLobbyHandler(responseDispatcher, lobbyManager, sessionManager)); // JOIN_LOBBY registration parserDispatcher.register( diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java index b86b145..87c9764 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java @@ -4,14 +4,22 @@ 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.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.SuccessResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; public class CreateLobbyHandler extends CommandHandler { private final LobbyManager lobbyManager; + private final SessionManager sessionManager; - public CreateLobbyHandler(ResponseDispatcher responseDispatcher, LobbyManager lobbyManager) { + public CreateLobbyHandler( + ResponseDispatcher responseDispatcher, LobbyManager lobbyManager, SessionManager sessionManager) { super(responseDispatcher); this.lobbyManager = lobbyManager; + this.sessionManager = sessionManager; } @Override @@ -28,5 +36,19 @@ public class CreateLobbyHandler extends CommandHandler { } responseDispatcher.dispatch(new CreateLobbyResponse(request.getContext(), id.value())); + + // broadcast LOBBY_CREATED event to all connected sessions (requestId=0) + for (Session s : sessionManager.getAllSessions()) { + RequestContext ctx = new RequestContext(s.getId(), 0); + SuccessResponse ev = + new SuccessResponse( + ctx, + ResponseBody.builder() + .param("EVENT", "LOBBY_CREATED") + .param("LOBBY_ID", id.value()) + .build()) {}; + + responseDispatcher.dispatch(ev); + } } }