Merge branch 'feat/change-username-at-start-or-in-game' into 'main'

Feat: accept optional client username and forward to ClientApp

See merge request cs108-fs26/Gruppe-13!115
This commit was merged in pull request #271.
This commit is contained in:
Jona Walpert
2026-04-12 09:17:38 +00:00
10 changed files with 377 additions and 63 deletions
@@ -7,6 +7,12 @@ import org.apache.logging.log4j.Logger;
/** Main entry point for Casono application. Handles client and server startup. */ /** Main entry point for Casono application. Handles client and server startup. */
public final class Main { 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. * Main entry point for Casono.
* *
@@ -17,10 +23,13 @@ public final class Main {
printUsage(); printUsage();
System.exit(1); System.exit(1);
} }
switch (args[0]) { switch (args[0]) {
case "server" -> ServerApp.start(args[1]); 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 -> { default -> {
printUsage(); printUsage();
System.exit(1); System.exit(1);
@@ -29,12 +38,15 @@ public final class Main {
} }
private static boolean isValid(String[] args) { private static boolean isValid(String[] args) {
if (args.length != 2) { if (args.length < ARGS_COUNT_SERVER) {
return false; return false;
} }
return switch (args[0]) { 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; default -> false;
}; };
} }
@@ -45,7 +57,7 @@ public final class Main {
""" """
Usage: Usage:
java -jar xyz.jar server <listenPort> java -jar xyz.jar server <listenPort>
java -jar xyz.jar client <serverIp>:<serverPort> java -jar xyz.jar client <serverIp>:<serverPort> [<username>]
"""); """);
} }
} }
@@ -1,33 +1,51 @@
package ch.unibas.dmi.dbis.cs108.casono.client; package ch.unibas.dmi.dbis.cs108.casono.client;
/** import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
* Entry point for the Casono client application. Handles client startup and connection parameters. 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 ch.unibas.dmi.dbis.cs108.casono.client.ui.Launcher;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; 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.
* *
* <p>Default constructor for the application. * <p>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 { public class ClientApp {
private static final Logger LOGGER = LogManager.getLogger(ClientApp.class); 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. */ /** Default constructor. */
public ClientApp() { public ClientApp() {
// Default constructor // 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. * Starts the client application with the given address.
* *
* @param arg Address in the format "ip:port". * @param arg Address in the format "ip:port".
* @throws IllegalArgumentException if the address format is invalid. * @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); String[] parts = arg.split(":", 2);
if (parts.length != 2) { if (parts.length != 2) {
throw new IllegalArgumentException("Address must be in format <ip>:<port>."); throw new IllegalArgumentException("Address must be in format <ip>:<port>.");
@@ -36,16 +54,69 @@ public class ClientApp {
int port = Integer.parseInt(parts[1]); int port = Integer.parseInt(parts[1]);
LOGGER.info("You've selected the client. It will connect port {} at host {}", port, host); 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.host", host);
System.setProperty("casono.server.port", Integer.toString(port)); System.setProperty("casono.server.port", String.valueOf(port));
// Forward the original address argument to the launcher as well. // If a username was provided, create a shared connection and perform
Launcher.main(new String[] {arg}); // 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 <host:port>} (e.g. {@code 127.0.0.1:1234})
*
* <p>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) { public static void main(String[] args) {
start(args[0]); if (args == null || args.length == 0) {
throw new IllegalArgumentException("Address argument required: <host:port>");
}
// Optional username argument
String username = args.length > 1 && args[1] != null && !args[1].isBlank() ? args[1] : null;
start(args[0], username);
} }
} }
@@ -138,33 +138,64 @@ public class ClientService {
() -> { () -> {
try { try {
writeToTransport(message); writeToTransport(message);
String responseText = null; String responseText = clienttcptransport.read().payload();
logger.info("Raw message '{}'", responseText);
responseText = clienttcptransport.read().payload(); boolean hasStatus = false;
logger.info("Raw message '" + responseText + "'"); boolean success = false;
Boolean success = null; for (String rawLine : responseText.split("\n")) {
int count = 0; String line = rawLine;
for (String line : responseText.split("\n")) { if (!hasStatus) {
if (success == null) {
if ("+OK".equals(line)) { if ("+OK".equals(line)) {
success = true; success = true;
hasStatus = true;
continue; 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; continue;
} else if ("END".equals(line)) { }
if ("END".equals(line)) {
break; 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); response.add(line);
} }
if (success != null && success) {
return; if (!hasStatus) {
} else { // Fallback for servers that do not place the status on a
throw new RuntimeException("Error in " + message + ": " + response); // 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) { } catch (Exception e) {
throw getRuntimeException(e); throw getRuntimeException(e);
} }
@@ -200,15 +231,14 @@ public class ClientService {
* @return A RuntimeException representing the cause of the original exception. * @return A RuntimeException representing the cause of the original exception.
*/ */
private static RuntimeException getRuntimeException(Exception e) { private static RuntimeException getRuntimeException(Exception e) {
Throwable reason = e.getCause(); Throwable cause = e.getCause();
RuntimeException re; if (cause == null) {
if (reason == null) { return e instanceof RuntimeException ? (RuntimeException) e : new RuntimeException(e);
reason = e;
} else if (reason instanceof RuntimeException rte) {
re = rte;
} }
re = new RuntimeException(reason); if (cause instanceof RuntimeException) {
return re; return (RuntimeException) cause;
}
return new RuntimeException(cause);
} }
/** /**
@@ -1,5 +1,8 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network; 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 * 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 * 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. * Logs in to the server with the given username by sending a "LOGIN" command.
* *
* @param user The username to log in with. * @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) { public LoginResult login(String user) {
client.processCommand("LOGIN USERNAME=" + user); List<String> lines = client.processCommand("LOGIN USERNAME=" + user);
List<RequestParameter> 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);
} }
} }
@@ -0,0 +1,38 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
/**
* Result of a successful login request.
*
* <p>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;
}
}
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui; 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.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import javafx.application.Platform; import javafx.application.Platform;
@@ -36,12 +37,16 @@ public class CasinomainuiController {
private int nextButtonId = 1; private int nextButtonId = 1;
private LobbyClient lobbyClient; private LobbyClient lobbyClient;
/** Default constructor for dependency injection by FXMLLoader. */ /** Default constructor used by FXMLLoader. */
public CasinomainuiController() { public CasinomainuiController() {
// Default constructor // 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 @FXML
public void initialize() { public void initialize() {
titleLabel.setText("Casono"); titleLabel.setText("Casono");
@@ -51,16 +56,18 @@ public class CasinomainuiController {
translationManager = LobbyButtonTranslationManager.getInstance(); translationManager = LobbyButtonTranslationManager.getInstance();
String host = System.getProperty("casono.server.host"); String host = System.getProperty("casono.server.host");
int port = Integer.parseInt(System.getProperty("casono.server.port")); int port = Integer.parseInt(System.getProperty("casono.server.port"));
ClientService clientService; ClientService clientService = ClientApp.getSharedClientService();
try { if (clientService == null) {
clientService = new ClientService(host, port); try {
} catch (RuntimeException e) { clientService = new ClientService(host, port);
LOGGER.warn( } catch (RuntimeException e) {
"Could not connect to server {}:{} — starting in offline mode: {}", LOGGER.warn(
host, "Could not connect to server {}:{} — starting in offline mode: {}",
port, host,
e.getMessage()); port,
clientService = new ClientService(true); // offline mode e.getMessage());
clientService = new ClientService(true); // offline mode
}
} }
gridManager = gridManager =
new LobbyButtonGridManager( new LobbyButtonGridManager(
@@ -117,7 +124,11 @@ public class CasinomainuiController {
Platform.exit(); 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 @FXML
public void handleCreateLobbyButton() { public void handleCreateLobbyButton() {
if (translationManager.isFull()) { if (translationManager.isFull()) {
@@ -13,7 +13,7 @@ import java.util.concurrent.ConcurrentLinkedQueue;
/** Represents an authenticated user on the server. */ /** Represents an authenticated user on the server. */
public class User { public class User {
private final UserId id; private final UserId id;
private final String name; private String name;
private SessionId sessionId; private SessionId sessionId;
private Instant disconnectedAt; private Instant disconnectedAt;
private final Queue<Message> messages; private final Queue<Message> messages;
@@ -47,10 +47,20 @@ public class User {
* *
* @return the user name * @return the user name
*/ */
public String getName() { public synchronized String getName() {
return name; 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. * Returns the session currently associated with this user, if any.
* *
@@ -1,10 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; 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. */ /** Creates new users, resolving name conflicts automatically. */
public class UserFactory { public class UserFactory {
private final UserRegistry registry; 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. * 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 * 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. * 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 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 * @return the newly created and registered user
*/ */
public User create(String desiredName, SessionId sessionId) { 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); var result = registry.registerIfAvailable(desiredName, sessionId);
if (result.isPresent()) { 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; int suffix = 1;
@@ -35,7 +64,13 @@ public class UserFactory {
String candidate = desiredName + "_" + String.format("%03d", suffix); String candidate = desiredName + "_" + String.format("%03d", suffix);
result = registry.registerIfAvailable(candidate, sessionId); result = registry.registerIfAvailable(candidate, sessionId);
if (result.isPresent()) { 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++; suffix++;
} }
@@ -185,4 +185,29 @@ public class UserRegistry {
public Collection<User> getAllUsers() { public Collection<User> getAllUsers() {
return byId.values(); 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;
}
} }
@@ -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());
}
}