From 3807059229de1ad49425bbf2ece0aec112585882 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:12:24 +0200 Subject: [PATCH 01/12] Feat: accept optional client username and forward to ClientApp Pass host/port and optional username via system properties for the UI. Refs #68 --- .../java/ch/unibas/dmi/dbis/cs108/casono/Main.java | 14 +++++++++----- .../dmi/dbis/cs108/casono/client/ClientApp.java | 8 +++++++- 2 files changed, 16 insertions(+), 6 deletions(-) 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..0c82c19 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 @@ -17,10 +17,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 >= 3 ? args[2] : null; + ClientApp.start(address, username); + } default -> { printUsage(); System.exit(1); @@ -29,12 +32,13 @@ public final class Main { } private static boolean isValid(String[] args) { - if (args.length != 2) { + if (args.length < 2) { return false; } return switch (args[0]) { - case "server", "client" -> true; + case "server" -> args.length == 2; + case "client" -> args.length == 2 || args.length == 3; default -> false; }; } @@ -45,7 +49,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 d5e2e2a..6b7267b 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 @@ -27,7 +27,7 @@ public class ClientApp { * @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,6 +36,12 @@ public class ClientApp { int port = Integer.parseInt(parts[1]); LOGGER.info("You've selected the client. It will connect port {} at host {}", port, host); + // Pass connection defaults and optional username to the UI via system properties + System.setProperty("casono.server.host", host); + System.setProperty("casono.server.port", String.valueOf(port)); + if (username != null && !username.isBlank()) { + System.setProperty("casono.username", username); + } Launcher.main(new String[] {}); } } -- 2.52.0 From c094b224c3c2a8d6cf9be0a384dfbfa67ed8bafb Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:20:21 +0200 Subject: [PATCH 02/12] Feat: Auto-assign playerN and add server-side rename support Make username mutable, assign playerN for anonymous logins, and add atomic changeUsername in UserRegistry. Refs #68 --- .../cs108/casono/server/domain/user/User.java | 27 ++++++-- .../server/domain/user/UserFactory.java | 25 ++++++-- .../server/domain/user/UserRegistry.java | 61 +++++++++++++++---- 3 files changed, 90 insertions(+), 23 deletions(-) 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 dc42ac3..a40e16f 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 @@ -12,7 +12,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; @@ -20,8 +20,8 @@ public class User { /** * Creates a new User with the given ID, name and session. * - * @param id the unique identifier for this user - * @param name the display name of this user + * @param id the unique identifier for this user + * @param name the display name of this user * @param sessionId the session currently associated with this user */ public User(UserId id, String name, SessionId sessionId) { @@ -46,10 +46,21 @@ 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. * @@ -62,7 +73,8 @@ public class User { /** * Returns the time at which this user disconnected, if applicable. * - * @return an Optional containing the disconnect timestamp, or empty if connected + * @return an Optional containing the disconnect timestamp, or empty if + * connected */ public Optional getDisconnectedAt() { return Optional.ofNullable(disconnectedAt); @@ -78,7 +90,10 @@ public class User { this.disconnectedAt = null; } - /** Marks this user as disconnected by clearing the session and recording the timestamp. */ + /** + * Marks this user as disconnected by clearing the session and recording the + * timestamp. + */ public void markDisconnected() { this.sessionId = null; this.disconnectedAt = Instant.now(); 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..559c833 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,12 @@ 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; /** Creates new users, resolving name conflicts automatically. */ public class UserFactory { private final UserRegistry registry; + private final AtomicInteger anonymousCounter = new AtomicInteger(1); /** * Creates a new UserFactory backed by the given registry. @@ -16,15 +18,30 @@ 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, ...). + * 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, ...). 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 user * @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()) { + return result.get(); + } + } + } + var result = registry.registerIfAvailable(desiredName, sessionId); if (result.isPresent()) { return result.get(); 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..3a91742 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 @@ -12,10 +12,11 @@ public class UserRegistry { private final ConcurrentHashMap bySessionId = new ConcurrentHashMap<>(); /** - * Attempts to register a user under the given name atomically. Returns the registered user, or + * Attempts to register a user under the given name atomically. Returns the + * registered user, or * empty if the name is already taken. * - * @param name the desired display name + * @param name the desired display name * @param sessionId the session to associate with the new user * @return an Optional containing the new user, or empty if the name was taken */ @@ -92,8 +93,10 @@ public class UserRegistry { } /** - * Removes the user with the given ID, but only if they are still disconnected. This prevents - * removing a user who has reconnected between the cleanup job's check and its removal call. + * Removes the user with the given ID, but only if they are still disconnected. + * This prevents + * removing a user who has reconnected between the cleanup job's check and its + * removal call. * * @param userId the ID of the user to remove */ @@ -112,7 +115,8 @@ public class UserRegistry { } /** - * Marks the user associated with the given session as disconnected, clearing the session + * Marks the user associated with the given session as disconnected, clearing + * the session * association and recording the disconnect timestamp. * * @param sessionId the session ID of the disconnected client @@ -127,9 +131,10 @@ public class UserRegistry { } /** - * Reassociates a user with a new session, effectively restoring them after a reconnect. + * Reassociates a user with a new session, effectively restoring them after a + * reconnect. * - * @param userId the ID of the user to reconnect + * @param userId the ID of the user to reconnect * @param sessionId the new session ID * @return an Optional containing the user, or empty if the user was not found */ @@ -148,8 +153,9 @@ public class UserRegistry { * Looks up a user by their {@link SessionId}. * * @param sessionId the SessionId to look up - * @return an Optional containing the {@link User}, or empty if no user is associated with this - * SessionId + * @return an Optional containing the {@link User}, or empty if no user is + * associated with this + * SessionId */ public Optional getBySessionId(SessionId sessionId) { return Optional.ofNullable(bySessionId.get(sessionId)); @@ -159,8 +165,9 @@ public class UserRegistry { * Looks up a user by their {@link UserId}. * * @param userId the UserId to look up - * @return an Optional containing the {@link User}, or empty if no user is associated with this - * UserId + * @return an Optional containing the {@link User}, or empty if no user is + * associated with this + * UserId */ public Optional getByUserId(UserId userId) { return Optional.ofNullable(byId.get(userId)); @@ -170,8 +177,9 @@ public class UserRegistry { * Looks up a user by their username. * * @param username the username to look up - * @return an Optional containing the {@link User}, or empty if no user is associated with this - * username + * @return an Optional containing the {@link User}, or empty if no user is + * associated with this + * username */ public Optional getByUsername(String username) { return Optional.ofNullable(byName.get(username)); @@ -185,4 +193,31 @@ 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; + } } -- 2.52.0 From 3be2d3d9f497847af978b1615c9bfe5c48a360bd Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:58:00 +0200 Subject: [PATCH 03/12] Fix: Chekstyle naming convention --- .../server/domain/user/UserFactoryTest.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactoryTest.java 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..edb6238 --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactoryTest.java @@ -0,0 +1,67 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; +import static org.junit.jupiter.api.Assertions.*; + +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()); + } +} -- 2.52.0 From 999dd9beb94d7c1c8cbae48e012b10f7960aafb7 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:59:27 +0200 Subject: [PATCH 04/12] Style: Apply Spotless --- .../cs108/casono/server/domain/user/User.java | 15 +++---- .../server/domain/user/UserFactory.java | 13 +++--- .../server/domain/user/UserRegistry.java | 42 +++++++------------ 3 files changed, 26 insertions(+), 44 deletions(-) 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 a40e16f..5052f97 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 @@ -20,8 +20,8 @@ public class User { /** * Creates a new User with the given ID, name and session. * - * @param id the unique identifier for this user - * @param name the display name of this user + * @param id the unique identifier for this user + * @param name the display name of this user * @param sessionId the session currently associated with this user */ public User(UserId id, String name, SessionId sessionId) { @@ -51,8 +51,7 @@ public class User { } /** - * Sets a new display name for this user. Thread-safe; callers must ensure the - * registry is + * 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 @@ -73,8 +72,7 @@ public class User { /** * Returns the time at which this user disconnected, if applicable. * - * @return an Optional containing the disconnect timestamp, or empty if - * connected + * @return an Optional containing the disconnect timestamp, or empty if connected */ public Optional getDisconnectedAt() { return Optional.ofNullable(disconnectedAt); @@ -90,10 +88,7 @@ public class User { this.disconnectedAt = null; } - /** - * Marks this user as disconnected by clearing the session and recording the - * timestamp. - */ + /** Marks this user as disconnected by clearing the session and recording the timestamp. */ public void markDisconnected() { this.sessionId = null; this.disconnectedAt = Instant.now(); 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 559c833..13fc889 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 @@ -18,16 +18,13 @@ 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, ...). If the desiredName is null or empty, an automatic - * name of the - * form "playerN" is assigned, incrementing N until a free name is found. + * 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, ...). 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) { 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 3a91742..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 @@ -12,11 +12,10 @@ public class UserRegistry { private final ConcurrentHashMap bySessionId = new ConcurrentHashMap<>(); /** - * Attempts to register a user under the given name atomically. Returns the - * registered user, or + * Attempts to register a user under the given name atomically. Returns the registered user, or * empty if the name is already taken. * - * @param name the desired display name + * @param name the desired display name * @param sessionId the session to associate with the new user * @return an Optional containing the new user, or empty if the name was taken */ @@ -93,10 +92,8 @@ public class UserRegistry { } /** - * Removes the user with the given ID, but only if they are still disconnected. - * This prevents - * removing a user who has reconnected between the cleanup job's check and its - * removal call. + * Removes the user with the given ID, but only if they are still disconnected. This prevents + * removing a user who has reconnected between the cleanup job's check and its removal call. * * @param userId the ID of the user to remove */ @@ -115,8 +112,7 @@ public class UserRegistry { } /** - * Marks the user associated with the given session as disconnected, clearing - * the session + * Marks the user associated with the given session as disconnected, clearing the session * association and recording the disconnect timestamp. * * @param sessionId the session ID of the disconnected client @@ -131,10 +127,9 @@ public class UserRegistry { } /** - * Reassociates a user with a new session, effectively restoring them after a - * reconnect. + * Reassociates a user with a new session, effectively restoring them after a reconnect. * - * @param userId the ID of the user to reconnect + * @param userId the ID of the user to reconnect * @param sessionId the new session ID * @return an Optional containing the user, or empty if the user was not found */ @@ -153,9 +148,8 @@ public class UserRegistry { * Looks up a user by their {@link SessionId}. * * @param sessionId the SessionId to look up - * @return an Optional containing the {@link User}, or empty if no user is - * associated with this - * SessionId + * @return an Optional containing the {@link User}, or empty if no user is associated with this + * SessionId */ public Optional getBySessionId(SessionId sessionId) { return Optional.ofNullable(bySessionId.get(sessionId)); @@ -165,9 +159,8 @@ public class UserRegistry { * Looks up a user by their {@link UserId}. * * @param userId the UserId to look up - * @return an Optional containing the {@link User}, or empty if no user is - * associated with this - * UserId + * @return an Optional containing the {@link User}, or empty if no user is associated with this + * UserId */ public Optional getByUserId(UserId userId) { return Optional.ofNullable(byId.get(userId)); @@ -177,9 +170,8 @@ public class UserRegistry { * Looks up a user by their username. * * @param username the username to look up - * @return an Optional containing the {@link User}, or empty if no user is - * associated with this - * username + * @return an Optional containing the {@link User}, or empty if no user is associated with this + * username */ public Optional getByUsername(String username) { return Optional.ofNullable(byName.get(username)); @@ -195,14 +187,12 @@ public class UserRegistry { } /** - * Attempts to change the username of an existing user. Ensures the new name is - * not already + * 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 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 + * @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); -- 2.52.0 From 8998152e8d87bcde1aa19314584a3a34c08c32e0 Mon Sep 17 00:00:00 2001 From: Julian Kropff Date: Sun, 12 Apr 2026 09:31:25 +0200 Subject: [PATCH 05/12] Fix: checkstyle --- .../ch/unibas/dmi/dbis/cs108/casono/Main.java | 16 ++++++++++++---- .../server/domain/user/UserFactoryTest.java | 7 ++----- 2 files changed, 14 insertions(+), 9 deletions(-) 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 0c82c19..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. * @@ -21,7 +27,7 @@ public final class Main { case "server" -> ServerApp.start(args[1]); case "client" -> { String address = args[1]; - String username = args.length >= 3 ? args[2] : null; + String username = args.length >= MIN_ARGS_FOR_USERNAME ? args[2] : null; ClientApp.start(address, username); } default -> { @@ -32,13 +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" -> args.length == 2; - case "client" -> args.length == 2 || args.length == 3; + case "server" -> args.length == ARGS_COUNT_SERVER; + case "client" -> + args.length == ARGS_COUNT_CLIENT_MIN + || args.length == ARGS_COUNT_CLIENT_WITH_USER; default -> false; }; } 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 index edb6238..0c6b2db 100644 --- 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 @@ -1,14 +1,11 @@ package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; -import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; 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. - */ +/** Tests for {@link UserFactory} and {@link UserRegistry} name assignment behavior. */ public class UserFactoryTest { // Logger removed as per patch requirement -- 2.52.0 From 4798de7499b2c5182e1ea64e97d555704816cd0f Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 10:42:56 +0200 Subject: [PATCH 06/12] Feat: Log assigned username and id on registration Add INFO log in UserFactory.create to record assigned username, user UUID and session id when a new user is registered. --- .../server/domain/user/UserFactory.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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 13fc889..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 @@ -2,11 +2,14 @@ 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. @@ -34,14 +37,26 @@ public class UserFactory { String candidate = "player" + anonymousCounter.getAndIncrement(); var 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; } } } 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; @@ -49,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++; } -- 2.52.0 From 0aafe13e34f806bfc77c08730f52fcab08c81e56 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 10:43:43 +0200 Subject: [PATCH 07/12] Fix: Treat login id as string (UUID) in LoginResult Change LoginResult.id from int to String and add Javadoc explaining the server returns a UUID. BREAKING CHANGE: LoginResult.getId() now returns String (UUID). --- .../casono/client/network/LoginResult.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LoginResult.java 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; + } +} -- 2.52.0 From 0ae88c9149b445d3abe5da362fd1c79a419fabad Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 10:45:03 +0200 Subject: [PATCH 08/12] Fix: Parse LOGIN response id as string in LobbyClient Parse the server ID parameter as a string and return it in LoginResult (no integer parsing). --- .../casono/client/network/LobbyClient.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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); } } -- 2.52.0 From c3e0e740ed33fe2318903dddee3e6c4fab159ecb Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 10:52:22 +0200 Subject: [PATCH 09/12] Feat: Keep startup LOGIN connection and expose shared ClientService Introduce ClientApp.sharedClientService, create it on startup when a username is provided, run the startup LOGIN asynchronously on that connection and keep the socket open so the UI reuses it; add Javadoc and adjust logging. --- .../dbis/cs108/casono/client/ClientApp.java | 91 +++++++++++++++++-- 1 file changed, 83 insertions(+), 8 deletions(-) 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 6b7267b..41fa767 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,26 +1,44 @@ 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. * @@ -36,12 +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); - // Pass connection defaults and optional username to the UI via system properties System.setProperty("casono.server.host", host); 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()) { - System.setProperty("casono.username", username); + 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 + } } - Launcher.main(new String[] {}); + + if (username != null && !username.isBlank()) { + Launcher.main(new String[] {arg, username}); + } else { + Launcher.main(new String[] {arg}); + } + } + + /** + * Main entry point. Usage: [username] + * + *

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) { + 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); } } -- 2.52.0 From 65a93a8c8201d19b91a1928958e67cb00473cf63 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 10:52:55 +0200 Subject: [PATCH 10/12] Feat: Reuse shared ClientService in CasinomainuiController Make the controller use ClientApp.getSharedClientService() if available; otherwise fall back to creating a new ClientService. Add/cleanup Javadoc. --- .../ui/lobbyui/CasinomainuiController.java | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) 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()) { -- 2.52.0 From 9635ee8ef9f9a5a3d3965427c7b2d8bcc6e252fa Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 10:56:22 +0200 Subject: [PATCH 11/12] Fix: robust response parsing and clearer errors in ClientService Detect +OK / -ERR / -ERROR, strip leading tabs, collect response lines reliably and improve exception unwrapping. --- .../casono/client/network/ClientService.java | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) 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); } /** -- 2.52.0 From d16c34844448ff32eb8ad21cb69d4ee0466ff5f9 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sun, 12 Apr 2026 11:14:30 +0200 Subject: [PATCH 12/12] Fix: fix javadoc for MR --- .../java/ch/unibas/dmi/dbis/cs108/casono/client/ClientApp.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 41fa767..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 @@ -105,7 +105,7 @@ public class ClientApp { } /** - * Main entry point. Usage: [username] + * 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 -- 2.52.0