diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/Main.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/Main.java index cbf1cc2..04046b2 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/Main.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/Main.java @@ -7,6 +7,12 @@ import org.apache.logging.log4j.Logger; /** Main entry point for Casono application. Handles client and server startup. */ public final class Main { + + private static final int MIN_ARGS_FOR_USERNAME = 3; + private static final int ARGS_COUNT_SERVER = 2; + private static final int ARGS_COUNT_CLIENT_MIN = 2; + private static final int ARGS_COUNT_CLIENT_WITH_USER = 3; + /** * Main entry point for Casono. * @@ -17,10 +23,13 @@ public final class Main { printUsage(); System.exit(1); } - switch (args[0]) { case "server" -> ServerApp.start(args[1]); - case "client" -> ClientApp.start(args[1]); + case "client" -> { + String address = args[1]; + String username = args.length >= MIN_ARGS_FOR_USERNAME ? args[2] : null; + ClientApp.start(address, username); + } default -> { printUsage(); System.exit(1); @@ -29,12 +38,15 @@ public final class Main { } private static boolean isValid(String[] args) { - if (args.length != 2) { + if (args.length < ARGS_COUNT_SERVER) { return false; } return switch (args[0]) { - case "server", "client" -> true; + case "server" -> args.length == ARGS_COUNT_SERVER; + case "client" -> + args.length == ARGS_COUNT_CLIENT_MIN + || args.length == ARGS_COUNT_CLIENT_WITH_USER; default -> false; }; } @@ -45,7 +57,7 @@ public final class Main { """ Usage: java -jar xyz.jar server - java -jar xyz.jar client : + java -jar xyz.jar client : [] """); } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ClientApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ClientApp.java index e9d5ff4..bdb4065 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ClientApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ClientApp.java @@ -1,33 +1,51 @@ package ch.unibas.dmi.dbis.cs108.casono.client; -/** - * Entry point for the Casono client application. Handles client startup and connection parameters. - */ +import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; +import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; +import ch.unibas.dmi.dbis.cs108.casono.client.network.LoginResult; import ch.unibas.dmi.dbis.cs108.casono.client.ui.Launcher; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; /** - * Entry point for the Casono client application. Handles client startup and connection parameters. + * Entry point and bootstrap helper for the Casono client application. * - *

Default constructor for the application. + *

This class is responsible for two tasks: - performing an optional startup LOGIN when a + * username is supplied on the command line, and - providing a shared {@link ClientService} instance + * that the UI can reuse so the initial connection remains active. */ public class ClientApp { private static final Logger LOGGER = LogManager.getLogger(ClientApp.class); + /** Shared client connection used when a username is provided at startup. */ + private static volatile ClientService sharedClientService; + /** Default constructor. */ public ClientApp() { // Default constructor } + /** + * Returns the shared {@link ClientService} instance created during startup, or {@code null} if + * none exists. The UI may call this to reuse the connection that already performed the initial + * LOGIN. + */ + public static ClientService getSharedClientService() { + return sharedClientService; + } + + private static void setSharedClientService(ClientService cs) { + sharedClientService = cs; + } + /** * Starts the client application with the given address. * * @param arg Address in the format "ip:port". * @throws IllegalArgumentException if the address format is invalid. */ - public static void start(String arg) { + public static void start(String arg, String username) { String[] parts = arg.split(":", 2); if (parts.length != 2) { throw new IllegalArgumentException("Address must be in format :."); @@ -36,16 +54,69 @@ public class ClientApp { int port = Integer.parseInt(parts[1]); LOGGER.info("You've selected the client. It will connect port {} at host {}", port, host); - // Expose the chosen host/port to the UI via system properties so controllers - // (which read System.getProperty("casono.server.host"/"casono.server.port")) - // can obtain the correct connection information. System.setProperty("casono.server.host", host); - System.setProperty("casono.server.port", Integer.toString(port)); - // Forward the original address argument to the launcher as well. - Launcher.main(new String[] {arg}); + System.setProperty("casono.server.port", String.valueOf(port)); + // If a username was provided, create a shared connection and perform + // the initial LOGIN asynchronously on that connection. The connection + // is intentionally NOT closed so the UI can reuse it. + if (username != null && !username.isBlank()) { + try { + ClientService clientService = new ClientService(host, port); + setSharedClientService(clientService); + + java.util.concurrent.ExecutorService bg = + java.util.concurrent.Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("casono-startup-login"); + return t; + }); + bg.submit( + () -> { + try { + LobbyClient lobbyClient = new LobbyClient(clientService); + LoginResult res = lobbyClient.login(username); + LOGGER.info( + "Assigned username='{}' id={}", + res.getUsername(), + res.getId()); + } catch (RuntimeException e) { + LOGGER.warn("Startup login failed: {}", e.getMessage()); + } catch (Exception e) { + LOGGER.warn("Unexpected error during startup login", e); + } finally { + bg.shutdown(); + } + }); + } catch (RuntimeException e) { + LOGGER.warn( + "Could not establish initial connection for startup login: {}", + e.getMessage()); + // UI will create its own connection when it initializes + } + } + + if (username != null && !username.isBlank()) { + Launcher.main(new String[] {arg, username}); + } else { + Launcher.main(new String[] {arg}); + } } + /** + * Main entry point. {@code } (e.g. {@code 127.0.0.1:1234}) + * + *

If a username is provided the application attempts a startup LOGIN on the shared + * connection; the UI will still start and will reuse the connection when possible. The username + * is not propagated via a system property for UI display. + */ public static void main(String[] args) { - start(args[0]); + if (args == null || args.length == 0) { + throw new IllegalArgumentException("Address argument required: "); + } + // Optional username argument + String username = args.length > 1 && args[1] != null && !args[1].isBlank() ? args[1] : null; + start(args[0], username); } } 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 ccf284e..e6b7643 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 @@ -138,33 +138,64 @@ public class ClientService { () -> { try { writeToTransport(message); - String responseText = null; + String responseText = clienttcptransport.read().payload(); + logger.info("Raw message '{}'", responseText); - responseText = clienttcptransport.read().payload(); - logger.info("Raw message '" + responseText + "'"); - Boolean success = null; - int count = 0; - for (String line : responseText.split("\n")) { - if (success == null) { + boolean hasStatus = false; + boolean success = false; + for (String rawLine : responseText.split("\n")) { + String line = rawLine; + if (!hasStatus) { if ("+OK".equals(line)) { success = true; + hasStatus = true; continue; - - } else if (("-ERROR").equals(responseText)) { - success = false; } + if (line.startsWith("-ERR") || line.startsWith("-ERROR")) { + success = false; + hasStatus = true; + continue; + } + // ignore any lines before the status indicator continue; - } else if ("END".equals(line)) { + } + + if ("END".equals(line)) { break; } - line = line.replaceFirst("^\t", ""); + + // strip a single leading tab if present (protocol formatting) + if (line.startsWith("\t")) { + line = line.substring(1); + } response.add(line); } - if (success != null && success) { - return; - } else { - throw new RuntimeException("Error in " + message + ": " + response); + + if (!hasStatus) { + // Fallback for servers that do not place the status on a + // dedicated line: try to detect status markers anywhere + // in the payload to remain compatible with older servers. + if (responseText.contains("+OK")) { + success = true; + hasStatus = true; + } else if (responseText.contains("-ERR") + || responseText.contains("-ERROR")) { + success = false; + hasStatus = true; + } else { + throw new RuntimeException( + "No status line in response for '" + + message + + "': " + + responseText); + } } + + if (success) { + return; + } + + throw new RuntimeException("Error in " + message + ": " + response); } catch (Exception e) { throw getRuntimeException(e); } @@ -200,15 +231,14 @@ public class ClientService { * @return A RuntimeException representing the cause of the original exception. */ private static RuntimeException getRuntimeException(Exception e) { - Throwable reason = e.getCause(); - RuntimeException re; - if (reason == null) { - reason = e; - } else if (reason instanceof RuntimeException rte) { - re = rte; + Throwable cause = e.getCause(); + if (cause == null) { + return e instanceof RuntimeException ? (RuntimeException) e : new RuntimeException(e); } - re = new RuntimeException(reason); - return re; + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + return new RuntimeException(cause); } /** diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java index 853ed56..6d43f19 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java @@ -1,5 +1,8 @@ package ch.unibas.dmi.dbis.cs108.casono.client.network; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter; +import java.util.List; + /** * The LobbyClient class is responsible for communicating with the server to manage game lobbies. It * provides methods to create a lobby, join a lobby, and fetch the current status of a lobby by @@ -65,8 +68,23 @@ public class LobbyClient { * Logs in to the server with the given username by sending a "LOGIN" command. * * @param user The username to log in with. + * @return a {@link LoginResult} containing the assigned username and id as returned by the + * server */ - public void login(String user) { - client.processCommand("LOGIN USERNAME=" + user); + public LoginResult login(String user) { + List lines = client.processCommand("LOGIN USERNAME=" + user); + + List params = ClientService.convertToRequestParameters(lines); + + String assigned = user; + String id = null; + for (RequestParameter p : params) { + if ("USERNAME".equalsIgnoreCase(p.key())) { + assigned = p.value(); + } else if ("ID".equalsIgnoreCase(p.key())) { + id = p.value(); + } + } + return new LoginResult(assigned, id); } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LoginResult.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LoginResult.java new file mode 100644 index 0000000..29dff70 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LoginResult.java @@ -0,0 +1,38 @@ +package ch.unibas.dmi.dbis.cs108.casono.client.network; + +/** + * Result of a successful login request. + * + *

Holds the username assigned by the server and the server-side id as a string (UUID). The + * client previously attempted to parse the id as an integer which failed when the server returned a + * UUID; this class therefore stores the id as a {@link String}. + */ +public class LoginResult { + private final String username; + private final String id; + + /** + * Create a login result. + * + * @param username the assigned username + * @param id the assigned id (UUID string) returned by the server + */ + public LoginResult(String username, String id) { + this.username = username; + this.id = id; + } + + /** + * @return the assigned username + */ + public String getUsername() { + return username; + } + + /** + * @return the assigned id as returned by the server (UUID string) + */ + public String getId() { + return id; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java index d18d36f..701f092 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java @@ -1,5 +1,6 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui; +import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp; import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import javafx.application.Platform; @@ -36,12 +37,16 @@ public class CasinomainuiController { private int nextButtonId = 1; private LobbyClient lobbyClient; - /** Default constructor for dependency injection by FXMLLoader. */ + /** Default constructor used by FXMLLoader. */ public CasinomainuiController() { // Default constructor } - /** Initializes the UI components and sets default values. */ + /** + * Initializes the UI components and sets default values. If a shared {@link + * ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService} exists (created at application + * start), the controller reuses it so the connection remains open and already-logged-in. + */ @FXML public void initialize() { titleLabel.setText("Casono"); @@ -51,16 +56,18 @@ public class CasinomainuiController { translationManager = LobbyButtonTranslationManager.getInstance(); String host = System.getProperty("casono.server.host"); int port = Integer.parseInt(System.getProperty("casono.server.port")); - ClientService clientService; - try { - clientService = new ClientService(host, port); - } catch (RuntimeException e) { - LOGGER.warn( - "Could not connect to server {}:{} — starting in offline mode: {}", - host, - port, - e.getMessage()); - clientService = new ClientService(true); // offline mode + ClientService clientService = ClientApp.getSharedClientService(); + if (clientService == null) { + try { + clientService = new ClientService(host, port); + } catch (RuntimeException e) { + LOGGER.warn( + "Could not connect to server {}:{} — starting in offline mode: {}", + host, + port, + e.getMessage()); + clientService = new ClientService(true); // offline mode + } } gridManager = new LobbyButtonGridManager( @@ -117,7 +124,11 @@ public class CasinomainuiController { Platform.exit(); } - /** Handles creation of a new lobby button. */ + /** + * Handles creation of a new lobby button. Attempts to create a lobby on the server via the + * {@link LobbyButtonGridManager} and registers the new button in the local translation manager. + * Errors are logged and displayed as an informational alert. + */ @FXML public void handleCreateLobbyButton() { if (translationManager.isFull()) { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java index f660c40..8afae3e 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java @@ -13,7 +13,7 @@ import java.util.concurrent.ConcurrentLinkedQueue; /** Represents an authenticated user on the server. */ public class User { private final UserId id; - private final String name; + private String name; private SessionId sessionId; private Instant disconnectedAt; private final Queue messages; @@ -47,10 +47,20 @@ public class User { * * @return the user name */ - public String getName() { + public synchronized String getName() { return name; } + /** + * Sets a new display name for this user. Thread-safe; callers must ensure the registry is + * updated to maintain uniqueness when needed. + * + * @param newName the new display name + */ + public synchronized void setName(String newName) { + this.name = newName; + } + /** * Returns the session currently associated with this user, if any. * diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java index 84ab1ce..0a41b0c 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java @@ -1,10 +1,15 @@ package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; /** Creates new users, resolving name conflicts automatically. */ public class UserFactory { private final UserRegistry registry; + private final AtomicInteger anonymousCounter = new AtomicInteger(1); + private static final Logger LOGGER = LogManager.getLogger(UserFactory.class); /** * Creates a new UserFactory backed by the given registry. @@ -18,16 +23,40 @@ public class UserFactory { /** * Creates and registers a new user with the given name and session. If the name is already * taken, a numeric suffix is appended and incremented until a free name is found (e.g. - * Lars_001, Lars_002, ...). + * Lars_001, Lars_002, ...). If the desiredName is null or empty, an automatic name of the form + * "playerN" is assigned, incrementing N until a free name is found. * * @param desiredName the preferred display name - * @param sessionId the session to associate with the new user + * @param sessionId the session to associate with the new userwas * @return the newly created and registered user */ public User create(String desiredName, SessionId sessionId) { + if (desiredName == null || desiredName.isBlank()) { + // assign playerN names for anonymous logins + while (true) { + String candidate = "player" + anonymousCounter.getAndIncrement(); + var result = registry.registerIfAvailable(candidate, sessionId); + if (result.isPresent()) { + var user = result.get(); + LOGGER.info( + "Registered user '{}' with id {} for session {}", + user.getName(), + user.getId().value(), + sessionId == null ? "null" : sessionId.value()); + return user; + } + } + } + var result = registry.registerIfAvailable(desiredName, sessionId); if (result.isPresent()) { - return result.get(); + var user = result.get(); + LOGGER.info( + "Registered user '{}' with id {} for session {}", + user.getName(), + user.getId().value(), + sessionId == null ? "null" : sessionId.value()); + return user; } int suffix = 1; @@ -35,7 +64,13 @@ public class UserFactory { String candidate = desiredName + "_" + String.format("%03d", suffix); result = registry.registerIfAvailable(candidate, sessionId); if (result.isPresent()) { - return result.get(); + var user = result.get(); + LOGGER.info( + "Registered user '{}' with id {} for session {}", + user.getName(), + user.getId().value(), + sessionId == null ? "null" : sessionId.value()); + return user; } suffix++; } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java index b3cd551..277ad8b 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java @@ -185,4 +185,29 @@ public class UserRegistry { public Collection getAllUsers() { return byId.values(); } + + /** + * Attempts to change the username of an existing user. Ensures the new name is not already + * taken and updates internal indices atomically. + * + * @param userId the id of the user to rename + * @param newName the desired new name + * @return true if the rename succeeded, false if the name was already taken or user not found + */ + public synchronized boolean changeUsername(UserId userId, String newName) { + User user = byId.get(userId); + if (user == null) { + return false; + } + + if (byName.containsKey(newName)) { + return false; + } + + // remove old mapping and put new mapping + byName.remove(user.getName()); + user.setName(newName); + byName.put(newName, user); + return true; + } } diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactoryTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactoryTest.java new file mode 100644 index 0000000..0c6b2db --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactoryTest.java @@ -0,0 +1,64 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import static org.junit.jupiter.api.Assertions.*; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; +import org.junit.jupiter.api.Test; + +/** Tests for {@link UserFactory} and {@link UserRegistry} name assignment behavior. */ +public class UserFactoryTest { + + // Logger removed as per patch requirement + + @Test + public void createUsersWithDuplicateDesiredNamesAssignsUniqueNames() { + UserRegistry registry = new UserRegistry(); + UserFactory factory = new UserFactory(registry); + + SessionId s1 = new SessionId(); + SessionId s2 = new SessionId(); + SessionId s3 = new SessionId(); + + // Create first user with desired name "Alice" + User u1 = factory.create("Alice", s1); + // Create second user requesting the same name + User u2 = factory.create("Alice", s2); + // Create third user requesting the same name again + User u3 = factory.create("Alice", s3); + + assertNotNull(u1); + assertNotNull(u2); + assertNotNull(u3); + + // All assigned names must be distinct + String n1 = u1.getName(); + String n2 = u2.getName(); + String n3 = u3.getName(); + + // assigned names: n1, n2, n3 + + assertNotEquals(n1, n2); + assertNotEquals(n1, n3); + assertNotEquals(n2, n3); + + // Registry should contain all assigned names + assertTrue(registry.getByUsername(n1).isPresent()); + assertTrue(registry.getByUsername(n2).isPresent()); + assertTrue(registry.getByUsername(n3).isPresent()); + } + + @Test + public void createUserWithNullDesiredAssignsPlayerN() { + UserRegistry registry = new UserRegistry(); + UserFactory factory = new UserFactory(registry); + + SessionId s1 = new SessionId(); + + User u = factory.create(null, s1); + assertNotNull(u); + String name = u.getName(); + // assigned anonymous name: name + assertTrue(name.startsWith("player"), "expected automatic name starting with 'player'"); + assertTrue(registry.getByUsername(name).isPresent()); + } +}