From 3807059229de1ad49425bbf2ece0aec112585882 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:12:24 +0200 Subject: [PATCH 01/33] 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[] {}); } } From c094b224c3c2a8d6cf9be0a384dfbfa67ed8bafb Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:20:21 +0200 Subject: [PATCH 02/33] 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; + } } From 3be2d3d9f497847af978b1615c9bfe5c48a360bd Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:58:00 +0200 Subject: [PATCH 03/33] 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()); + } +} From 999dd9beb94d7c1c8cbae48e012b10f7960aafb7 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 10 Apr 2026 14:59:27 +0200 Subject: [PATCH 04/33] 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); From ca4283bbe7c01eabbff501e0138a322aa11170d2 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sat, 11 Apr 2026 16:38:57 +0200 Subject: [PATCH 05/33] Feat: Add createLobby command on server side Refs #93 --- .../create_lobby/CreateLobbyHandler.java | 32 +++++++++++++++++++ .../lobby/create_lobby/CreateLobbyParser.java | 11 +++++++ .../create_lobby/CreateLobbyRequest.java | 10 ++++++ .../create_lobby/CreateLobbyResponse.java | 14 ++++++++ 4 files changed, 67 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyParser.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyRequest.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyResponse.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java new file mode 100644 index 0000000..b86b145 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyHandler.java @@ -0,0 +1,32 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; + +public class CreateLobbyHandler extends CommandHandler { + private final LobbyManager lobbyManager; + + public CreateLobbyHandler(ResponseDispatcher responseDispatcher, LobbyManager lobbyManager) { + super(responseDispatcher); + this.lobbyManager = lobbyManager; + } + + @Override + public void execute(CreateLobbyRequest request) { + LobbyId id = lobbyManager.createNewLobby(null); + + if (id == null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), + "LOBBIES_FULL", + "Maximum number of 8 lobbies reached")); + return; + } + + responseDispatcher.dispatch(new CreateLobbyResponse(request.getContext(), id.value())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyParser.java new file mode 100644 index 0000000..00040f8 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyParser.java @@ -0,0 +1,11 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; + +public class CreateLobbyParser implements CommandParser { + @Override + public CreateLobbyRequest parse(PrimitiveRequest primitiveRequest) { + return new CreateLobbyRequest(primitiveRequest.context()); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyRequest.java new file mode 100644 index 0000000..669a0d0 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyRequest.java @@ -0,0 +1,10 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +public class CreateLobbyRequest extends Request { + public CreateLobbyRequest(RequestContext context) { + super(context); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyResponse.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyResponse.java new file mode 100644 index 0000000..cb7371f --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/lobby/create_lobby/CreateLobbyResponse.java @@ -0,0 +1,14 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBodyBuilder; + +/** Sends ID of newly created lobby back to client. */ +public class CreateLobbyResponse extends SuccessResponse { + public CreateLobbyResponse(RequestContext context, int lobbyId) { + super( + context, + new ResponseBodyBuilder().param("LOBBY_ID", String.valueOf(lobbyId)).build()); + } +} From 62f01d1e5da34c98b947cf94ecbdb6c7f27c4293 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Sat, 11 Apr 2026 16:44:19 +0200 Subject: [PATCH 06/33] Docs: add documentation of create_lobby commad --- .../networking/commands/protocol-document.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index 340dc8e..3f61212 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -453,4 +453,58 @@ GET_NEXT_MESSAGE +OK TYPE=GLOBAL GAME=-1 USER=player1 TARGET=null TIME=9:30 TEXT="Guten Tag" END +``` + +## CREATE_LOBBY command + +The `CREATE_LOBBY` command requests the server to create a new lobby and return its identifier. + +### Required pre-execution checks + +None. + +### Request Parameters + +No parameters. + +### Implementation notes + +- Parser: CreateLobbyParser — constructs a CreateLobbyRequest from the incoming PrimitiveRequest context (no parameters are read). +- Handler: CreateLobbyHandler — calls LobbyManager.createNewLobby(null). + - If the returned LobbyId is null, the handler dispatches an ErrorResponse with code `LOBBIES_FULL` and message "Maximum number of 8 lobbies reached". + - On success, the handler dispatches a CreateLobbyResponse containing the new lobby id. + +### Success Response + +| Field | Type | Description | +| :-------- | :--- | :---------- | +| `LOBBY_ID`| `int`| Numeric id of the newly created lobby | + +### Error Response + +| Code | Description | +| :---------- | :--------------------------------------------------------------------- | +| `LOBBIES_FULL` | The server cannot create a new lobby because the maximum number of lobbies has been reached | + +### Example Request + +``` +CREATE_LOBBY +``` + +### Example Success Response + +``` ++OK + LOBBY_ID=1 +END +``` + +### Example Error Response + +``` +-ERR + CODE=LOBBIES_FULL + MESSAGE=Maximum number of 8 lobbies reached +END ``` \ No newline at end of file From 0f93bb8b858b1cef1bfcacfc271f39696dbe15c6 Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sat, 11 Apr 2026 23:03:21 +0200 Subject: [PATCH 07/33] Docs: Removed README of blog --- documents/blog/README.md | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 documents/blog/README.md diff --git a/documents/blog/README.md b/documents/blog/README.md deleted file mode 100644 index aa92b15..0000000 --- a/documents/blog/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Blog - -## Purpose -This folder contains a collection of blog posts documenting the development process of our application. These blogs serve two key purposes: - -1. **For External Audiences** - - The blogs provide insights into the internal program flow and the interaction between components without requiring readers to dive into the source code. They offer a mixed-level overview of how different parts of the system work together. - -2. **For Our Team** - - The blogs serve as a record of our decision-making process. By documenting what decisions were made and why, we can track the evolution of our design choices and understand the reasoning behind them. This is invaluable for maintaining consistency across the team. - -But keep in mind that these blogs **do not replace** the detailed source code documentation. - -## Format -All blog posts follow the naming convention: `_.md` - -## Blog Posts - -### General -*(No posts yet)* - -### Client -*(No posts yet)* - -### Server -*(No posts yet)* From 31361ffb8366d5389f359f3cd4701009d708e15f Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer <lars.winzer@stud.unibas.ch> Date: Sat, 11 Apr 2026 23:06:34 +0200 Subject: [PATCH 08/33] Docs: Remove blog folder from main readme in repository structure --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index bd9fc2c..ae2e8a8 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ This protocol handles game state synchronization, player actions, lobby and glob ├── documents/ # Project resources and documentation │ ├── images/ # Images for README and documentation │ ├── diary # Diary files for each organizational meetup -│ ├── blog # Blog files covering project-related topics │ ├── docs # Documentation │ ├── milestones/ # Milestone deliverables (6 milestones) │ └── (Other resources) # Additional project materials From f5a62c6e934a382b993d4ab8742999d92dbe24df Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:11:11 +0200 Subject: [PATCH 09/33] Feat: add bet command on server side --- .../dbis/cs108/casono/server/ServerApp.java | 12 ++ .../commands/game/bet/PlayerBetHandler.java | 107 ++++++++++++++++++ .../commands/game/bet/PlayerBetParser.java | 40 +++++++ .../commands/game/bet/PlayerBetRequest.java | 45 ++++++++ 4 files changed, 204 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetParser.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetRequest.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index f9007eb..7ca3406 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -169,6 +169,18 @@ public class ServerApp { .GetGameStateHandler( responseDispatcher, lobbyManager, userRegistry)); + // BET registration + parserDispatcher.register( + "BET", + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetParser()); + commandRouter.register( + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetRequest.class, + (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet + .PlayerBetRequest>) + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet + .PlayerBetHandler(responseDispatcher, userRegistry, lobbyManager)); + // GET_LOBBY_STATUS registration parserDispatcher.register( "GET_LOBBY_STATUS", diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java new file mode 100644 index 0000000..eeca227 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java @@ -0,0 +1,107 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import java.util.Optional; + +/** + * Handler for the `BET` command. + * + * <p>This handler validates the request (amount >= 0), resolves the user and target lobby (by + * `GAME_ID` when provided or by session username otherwise), checks that a game is running and then + * forwards the action to the {@code GameController}. + * + * <p>On failure the handler dispatches an {@link ErrorResponse} with an appropriate error code + * (e.g. {@code INVALID_AMOUNT}, {@code NOT_IN_LOBBY}, {@code LOBBY_NOT_FOUND}, {@code + * GAME_NOT_STARTED}, {@code GAME_ACTION_FAILED}). On success it dispatches an {@link OkResponse}. + */ +public class PlayerBetHandler extends CommandHandler<PlayerBetRequest> { + private final UserRegistry userRegistry; + private final LobbyManager lobbyManager; + + /** + * Creates a new {@code PlayerBetHandler}. + * + * @param responseDispatcher dispatcher used to send responses back to the client + * @param userRegistry registry to resolve users from session ids + * @param lobbyManager manager used to lookup lobbies and their game controllers + */ + public PlayerBetHandler( + ResponseDispatcher responseDispatcher, + UserRegistry userRegistry, + LobbyManager lobbyManager) { + super(responseDispatcher); + this.userRegistry = userRegistry; + this.lobbyManager = lobbyManager; + } + + /** + * Execute the bet request: validate parameters, resolve lobby and game, and forward the bet + * action to the game controller. Sends an {@link ErrorResponse} on failure or an {@link + * OkResponse} on success. + * + * @param request the parsed {@link PlayerBetRequest} + */ + @Override + public void execute(PlayerBetRequest request) { + if (request.getAmount() < 0) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "INVALID_AMOUNT", "Amount must be non-negative")); + return; + } + + Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt = + userRegistry.getBySessionId(request.getSessionId()); + if (opt.isEmpty()) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in")); + return; + } + + String username = opt.get().getName(); + + Integer gameId = request.getGameId(); + var lobby = + (gameId != null) + ? lobbyManager.getLobby(LobbyId.of(gameId)) + : lobbyManager.getLobbyByUsername(username); + + if (lobby == null) { + if (gameId != null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found")); + } else { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "NOT_IN_LOBBY", "User not in a lobby")); + } + return; + } + + var game = lobby.getGameController(); + if (game == null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "GAME_NOT_STARTED", "Game not started")); + return; + } + + try { + game.playerBet(PlayerId.of(username), request.getAmount()); + } catch (Exception e) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "GAME_ACTION_FAILED", e.getMessage())); + return; + } + + responseDispatcher.dispatch(new OkResponse(request.getContext())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetParser.java new file mode 100644 index 0000000..0ed9a1d --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetParser.java @@ -0,0 +1,40 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor; + +/** + * Parser for the `BET` command. + * + * <p>Parses the request parameters and builds a {@link PlayerBetRequest}. Expected parameters: + * + * <ul> + * <li>`GAME_ID` (optional) - numeric id of the lobby/game to target + * <li>`AMOUNT` (required) - amount to bet + * </ul> + */ +public class PlayerBetParser implements CommandParser<PlayerBetRequest> { + /** + * Parse a primitive request into a {@link PlayerBetRequest}. + * + * @param primitiveRequest the raw parsed request containing parameters and context + * @return a {@link PlayerBetRequest} with the parsed values (gameId may be null) + */ + @Override + public PlayerBetRequest parse(PrimitiveRequest primitiveRequest) { + RequestParameterAccessor accessor = + new RequestParameterAccessor(primitiveRequest.parameters()); + + Integer gameId = null; + try { + gameId = accessor.optional("GAME_ID", null, Integer::parseInt); + } catch (Exception e) { + // parse handled by framework + } + + int amount = accessor.require("AMOUNT", Integer::parseInt); + + return new PlayerBetRequest(primitiveRequest.context(), gameId, amount); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetRequest.java new file mode 100644 index 0000000..02c70a8 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetRequest.java @@ -0,0 +1,45 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +/** + * Request for the `BET` command. + * + * <p>Contains the optional target `gameId` and the `amount` the player wants to bet. + */ +public class PlayerBetRequest extends Request { + private final Integer gameId; + private final int amount; + + /** + * Creates a new {@code PlayerBetRequest}. + * + * @param context the request context + * @param gameId optional game id of the targeted lobby (may be {@code null}) + * @param amount the bet amount (non-negative) + */ + public PlayerBetRequest(RequestContext context, Integer gameId, int amount) { + super(context); + this.gameId = gameId; + this.amount = amount; + } + + /** + * Returns the optional target game id. + * + * @return the game id or {@code null} if not provided + */ + public Integer getGameId() { + return gameId; + } + + /** + * Returns the requested bet amount. + * + * @return the bet amount + */ + public int getAmount() { + return amount; + } +} From d77ce42795c84fecec0e9138afa291e13fda7dcd Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:11:30 +0200 Subject: [PATCH 10/33] Docs: add documentation of bet command --- .../networking/commands/protocol-document.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index 8ac0631..d22cbb2 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -62,6 +62,7 @@ This document describes the protocol for client-server communication in our appl - [Success Response](#success-response) - [Example Request](#example-request) - [Example Response](#example-response) + - [BET command](#bet-command) - [SEND_MESSAGE command](#send_message-command) - [Required pre-execution checks](#required-pre-execution-checks) - [Request Parameters](#request-parameters) @@ -663,6 +664,63 @@ GET_GAME_STATE GAME_ID=1 END ``` +## BET command + +The `BET` command lets the currently logged-in player place a bet in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | +| `AMOUNT` | `int` | no | Amount the player wants to bet | + +### Implementation notes + +- Parser: `PlayerBetParser` — reads `AMOUNT` and optionally `GAME_ID`. +- Handler: `PlayerBetHandler` — validates the session, lobby (by id when provided or else by session), player's turn and balance and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to bet when not their turn | +| `INSUFFICIENT_FUNDS` | Player does not have enough chips | +| `INVALID_AMOUNT` | Amount parameter is invalid | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +BET GAME_ID=1 AMOUNT=50 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=INSUFFICIENT_FUNDS + MSG=Not enough chips +END +``` + ## GET_LOBBY_STATUS command The `GET_LOBBY_STATUS` command requests the server to return the current state of a lobby, including the list of players and their ready state. From 9146d92c04ee2600b5d6ad300bbb82a21378eb8d Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:27:45 +0200 Subject: [PATCH 11/33] Fix: fix MR Pipeline --- .../casono/server/app/commands/game/bet/PlayerBetHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java index eeca227..ea39326 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/bet/PlayerBetHandler.java @@ -1,5 +1,6 @@ package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; @@ -86,7 +87,7 @@ public class PlayerBetHandler extends CommandHandler<PlayerBetRequest> { return; } - var game = lobby.getGameController(); + GameController game = lobby.getGameController(); if (game == null) { responseDispatcher.dispatch( new ErrorResponse( From a4e267cb000c002f892eb1d6e7eeaf26c99d9387 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:34:32 +0200 Subject: [PATCH 12/33] Fix: add missing file for MR pipeline --- .../casono/server/domain/game/GameController.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java index aa5585a..02254ba 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameController.java @@ -1,5 +1,6 @@ package ch.unibas.dmi.dbis.cs108.casono.server.domain.game; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.BetAction; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.BlindAction; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.CallAction; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.FoldAction; @@ -180,6 +181,16 @@ public class GameController { engine.processAction(new RaiseAction(playerId, amount)); } + /** + * Processes a player's bet action by sending a BetAction to the GameEngine. + * + * @param playerId The ID of the player who is betting. + * @param amount The amount the player is betting. + */ + public void playerBet(PlayerId playerId, int amount) { + engine.processAction(new BetAction(playerId, amount)); + } + /** * Retrieves the current game state from the GameEngine. * From 0556767d400e6eaca093ad3423045043f93ddcf0 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:47:24 +0200 Subject: [PATCH 13/33] Feat: Add Raise command on backend side --- .../dbis/cs108/casono/server/ServerApp.java | 15 +++ .../game/raise/PlayerRaiseHandler.java | 120 ++++++++++++++++++ .../game/raise/PlayerRaiseParser.java | 35 +++++ .../game/raise/PlayerRaiseRequest.java | 47 +++++++ 4 files changed, 217 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index 7ca3406..e2e6f2a 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -181,6 +181,21 @@ public class ServerApp { new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet .PlayerBetHandler(responseDispatcher, userRegistry, lobbyManager)); + // RAISE registration + parserDispatcher.register( + "RAISE", + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise + .PlayerRaiseParser()); + commandRouter.register( + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest + .class, + (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise + .PlayerRaiseRequest>) + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise + .PlayerRaiseHandler( + responseDispatcher, userRegistry, lobbyManager)); + // GET_LOBBY_STATUS registration parserDispatcher.register( "GET_LOBBY_STATUS", diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java new file mode 100644 index 0000000..4d7b647 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java @@ -0,0 +1,120 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import java.util.Optional; + +/** + * Handler for the `RAISE` command. + * + * <p> + * This handler validates the request (amount >= 0), resolves the user and + * target lobby (by + * `GAME_ID` when provided or by session username otherwise), checks that a game + * is running and then + * forwards the action to the {@code GameController}. + * + * <p> + * On failure the handler dispatches an {@link ErrorResponse} with an + * appropriate error code + * (e.g. {@code INVALID_AMOUNT}, {@code NOT_IN_LOBBY}, {@code LOBBY_NOT_FOUND}, + * {@code + * GAME_NOT_STARTED}, {@code GAME_ACTION_FAILED}). On success it dispatches an + * {@link OkResponse}. + */ +public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> { + private final UserRegistry userRegistry; + private final LobbyManager lobbyManager; + + /** + * Creates a new {@code PlayerRaiseHandler}. + * + * @param responseDispatcher dispatcher used to send responses back to the + * client + * @param userRegistry registry to resolve users from session ids + * @param lobbyManager manager used to lookup lobbies and their game + * controllers + */ + public PlayerRaiseHandler( + ResponseDispatcher responseDispatcher, + UserRegistry userRegistry, + LobbyManager lobbyManager) { + super(responseDispatcher); + this.userRegistry = userRegistry; + this.lobbyManager = lobbyManager; + } + + /** + * Execute the raise request: validate parameters, resolve lobby and game, and + * forward the raise + * action to the game controller. Sends an {@link ErrorResponse} on failure or + * an {@link + * OkResponse} on success. + * + * @param request the parsed {@link PlayerRaiseRequest} + */ + @Override + public void execute(PlayerRaiseRequest request) { + int amount = request.getAmount(); + + if (amount < 0) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "INVALID_AMOUNT", "Amount must be non-negative")); + return; + } + + Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt = userRegistry + .getBySessionId(request.getSessionId()); + if (opt.isEmpty()) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in")); + return; + } + + String username = opt.get().getName(); + + Integer gameId = request.getGameId(); + var lobby = (gameId != null) + ? lobbyManager.getLobby(LobbyId.of(gameId)) + : lobbyManager.getLobbyByUsername(username); + + if (lobby == null) { + if (gameId != null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found")); + } else { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "NOT_IN_LOBBY", "User not in a lobby")); + } + return; + } + + GameController game = lobby.getGameController(); + if (game == null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "GAME_NOT_STARTED", "Game not started")); + return; + } + + try { + game.playerRaise(PlayerId.of(username), amount); + } catch (RuntimeException e) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "GAME_ACTION_FAILED", e.getMessage())); + return; + } + + responseDispatcher.dispatch(new OkResponse(request.getContext())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java new file mode 100644 index 0000000..69aac59 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java @@ -0,0 +1,35 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor; + +/** + * Parser for the `RAISE` command. + * + * <p> + * Parses the request parameters and builds a {@link PlayerRaiseRequest}. + * Expected parameters: + * + * <ul> + * <li>`GAME_ID` (optional) - numeric id of the lobby/game to target + * <li>`AMOUNT` (required) - amount to raise + * </ul> + */ +public class PlayerRaiseParser implements CommandParser<PlayerRaiseRequest> { + @Override + public PlayerRaiseRequest parse(PrimitiveRequest primitiveRequest) { + RequestParameterAccessor accessor = new RequestParameterAccessor(primitiveRequest.parameters()); + + Integer gameId = null; + try { + gameId = accessor.optional("GAME_ID", null, Integer::parseInt); + } catch (Exception e) { + // parse handled by framework + } + + int amount = accessor.require("AMOUNT", Integer::parseInt); + + return new PlayerRaiseRequest(primitiveRequest.context(), gameId, amount); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java new file mode 100644 index 0000000..65e5f78 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java @@ -0,0 +1,47 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +/** + * Request for the `RAISE` command. + * + * <p> + * Contains the optional target `gameId` and the `amount` the player wants to + * raise. + */ +public class PlayerRaiseRequest extends Request { + private final Integer gameId; + private final int amount; + + /** + * Creates a new {@code PlayerRaiseRequest}. + * + * @param context the request context + * @param gameId optional game id of the targeted lobby (may be {@code null}) + * @param amount the raise amount (non-negative) + */ + public PlayerRaiseRequest(RequestContext context, Integer gameId, int amount) { + super(context); + this.gameId = gameId; + this.amount = amount; + } + + /** + * Returns the optional target game id. + * + * @return the game id or {@code null} if not provided + */ + public Integer getGameId() { + return gameId; + } + + /** + * Returns the requested raise amount. + * + * @return the raise amount + */ + public int getAmount() { + return amount; + } +} From dac4dcfdbe25e2396164c1c6cfd54ec2a9301405 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:47:41 +0200 Subject: [PATCH 14/33] Docs: Add documentation for Raise command --- .../networking/commands/protocol-document.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index d22cbb2..f29b3dd 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -62,6 +62,7 @@ This document describes the protocol for client-server communication in our appl - [Success Response](#success-response) - [Example Request](#example-request) - [Example Response](#example-response) + - [RAISE command](#raise-command) - [BET command](#bet-command) - [SEND_MESSAGE command](#send_message-command) - [Required pre-execution checks](#required-pre-execution-checks) @@ -721,6 +722,63 @@ END END ``` +## RAISE command + +The `RAISE` command lets the currently logged-in player increase the current bet in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | +| `AMOUNT` | `int` | no | Amount the player wants to raise | + +### Implementation notes + +- Parser: `PlayerRaiseParser` — reads `AMOUNT` and optionally `GAME_ID`. +- Handler: `PlayerRaiseHandler` — validates the session, lobby (by id when provided or else by session), player's turn and balance and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to raise when not their turn | +| `INSUFFICIENT_FUNDS` | Player does not have enough chips | +| `INVALID_AMOUNT` | Amount parameter is invalid | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +RAISE GAME_ID=1 AMOUNT=100 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=INSUFFICIENT_FUNDS + MSG=Not enough chips +END +``` + ## GET_LOBBY_STATUS command The `GET_LOBBY_STATUS` command requests the server to return the current state of a lobby, including the list of players and their ready state. From ca818d98922eb426a77e186377765d86dc5e57d9 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 00:54:10 +0200 Subject: [PATCH 15/33] Fix: Checkstyle Line length for MR Pipeline --- .../game/raise/PlayerRaiseHandler.java | 42 +++++++------------ .../game/raise/PlayerRaiseParser.java | 11 +++-- .../game/raise/PlayerRaiseRequest.java | 8 ++-- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java index 4d7b647..6fbaaca 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseHandler.java @@ -14,20 +14,13 @@ import java.util.Optional; /** * Handler for the `RAISE` command. * - * <p> - * This handler validates the request (amount >= 0), resolves the user and - * target lobby (by - * `GAME_ID` when provided or by session username otherwise), checks that a game - * is running and then + * <p>This handler validates the request (amount >= 0), resolves the user and target lobby (by + * `GAME_ID` when provided or by session username otherwise), checks that a game is running and then * forwards the action to the {@code GameController}. * - * <p> - * On failure the handler dispatches an {@link ErrorResponse} with an - * appropriate error code - * (e.g. {@code INVALID_AMOUNT}, {@code NOT_IN_LOBBY}, {@code LOBBY_NOT_FOUND}, - * {@code - * GAME_NOT_STARTED}, {@code GAME_ACTION_FAILED}). On success it dispatches an - * {@link OkResponse}. + * <p>On failure the handler dispatches an {@link ErrorResponse} with an appropriate error code + * (e.g. {@code INVALID_AMOUNT}, {@code NOT_IN_LOBBY}, {@code LOBBY_NOT_FOUND}, {@code + * GAME_NOT_STARTED}, {@code GAME_ACTION_FAILED}). On success it dispatches an {@link OkResponse}. */ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> { private final UserRegistry userRegistry; @@ -36,11 +29,9 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> { /** * Creates a new {@code PlayerRaiseHandler}. * - * @param responseDispatcher dispatcher used to send responses back to the - * client - * @param userRegistry registry to resolve users from session ids - * @param lobbyManager manager used to lookup lobbies and their game - * controllers + * @param responseDispatcher dispatcher used to send responses back to the client + * @param userRegistry registry to resolve users from session ids + * @param lobbyManager manager used to lookup lobbies and their game controllers */ public PlayerRaiseHandler( ResponseDispatcher responseDispatcher, @@ -52,10 +43,8 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> { } /** - * Execute the raise request: validate parameters, resolve lobby and game, and - * forward the raise - * action to the game controller. Sends an {@link ErrorResponse} on failure or - * an {@link + * Execute the raise request: validate parameters, resolve lobby and game, and forward the raise + * action to the game controller. Sends an {@link ErrorResponse} on failure or an {@link * OkResponse} on success. * * @param request the parsed {@link PlayerRaiseRequest} @@ -71,8 +60,8 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> { return; } - Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt = userRegistry - .getBySessionId(request.getSessionId()); + Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt = + userRegistry.getBySessionId(request.getSessionId()); if (opt.isEmpty()) { responseDispatcher.dispatch( new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in")); @@ -82,9 +71,10 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> { String username = opt.get().getName(); Integer gameId = request.getGameId(); - var lobby = (gameId != null) - ? lobbyManager.getLobby(LobbyId.of(gameId)) - : lobbyManager.getLobbyByUsername(username); + var lobby = + (gameId != null) + ? lobbyManager.getLobby(LobbyId.of(gameId)) + : lobbyManager.getLobbyByUsername(username); if (lobby == null) { if (gameId != null) { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java index 69aac59..efbdfe7 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseParser.java @@ -7,19 +7,18 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor. /** * Parser for the `RAISE` command. * - * <p> - * Parses the request parameters and builds a {@link PlayerRaiseRequest}. - * Expected parameters: + * <p>Parses the request parameters and builds a {@link PlayerRaiseRequest}. Expected parameters: * * <ul> - * <li>`GAME_ID` (optional) - numeric id of the lobby/game to target - * <li>`AMOUNT` (required) - amount to raise + * <li>`GAME_ID` (optional) - numeric id of the lobby/game to target + * <li>`AMOUNT` (required) - amount to raise * </ul> */ public class PlayerRaiseParser implements CommandParser<PlayerRaiseRequest> { @Override public PlayerRaiseRequest parse(PrimitiveRequest primitiveRequest) { - RequestParameterAccessor accessor = new RequestParameterAccessor(primitiveRequest.parameters()); + RequestParameterAccessor accessor = + new RequestParameterAccessor(primitiveRequest.parameters()); Integer gameId = null; try { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java index 65e5f78..15aeb79 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/raise/PlayerRaiseRequest.java @@ -6,9 +6,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestCo /** * Request for the `RAISE` command. * - * <p> - * Contains the optional target `gameId` and the `amount` the player wants to - * raise. + * <p>Contains the optional target `gameId` and the `amount` the player wants to raise. */ public class PlayerRaiseRequest extends Request { private final Integer gameId; @@ -18,8 +16,8 @@ public class PlayerRaiseRequest extends Request { * Creates a new {@code PlayerRaiseRequest}. * * @param context the request context - * @param gameId optional game id of the targeted lobby (may be {@code null}) - * @param amount the raise amount (non-negative) + * @param gameId optional game id of the targeted lobby (may be {@code null}) + * @param amount the raise amount (non-negative) */ public PlayerRaiseRequest(RequestContext context, Integer gameId, int amount) { super(context); From 6d8f1787a9f4547904c6551249fad34bff0f49ae Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 01:06:17 +0200 Subject: [PATCH 16/33] Feat: add Call command to server side --- .../dbis/cs108/casono/server/ServerApp.java | 14 +++ .../commands/game/call/PlayerCallHandler.java | 96 +++++++++++++++++++ .../commands/game/call/PlayerCallParser.java | 28 ++++++ .../commands/game/call/PlayerCallRequest.java | 34 +++++++ 4 files changed, 172 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallHandler.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallParser.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallRequest.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index e2e6f2a..eef8e31 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -196,6 +196,20 @@ public class ServerApp { .PlayerRaiseHandler( responseDispatcher, userRegistry, lobbyManager)); + // CALL registration + parserDispatcher.register( + "CALL", + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call + .PlayerCallParser()); + commandRouter.register( + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallRequest + .class, + (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call + .PlayerCallRequest>) + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call + .PlayerCallHandler(responseDispatcher, userRegistry, lobbyManager)); + // GET_LOBBY_STATUS registration parserDispatcher.register( "GET_LOBBY_STATUS", diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallHandler.java new file mode 100644 index 0000000..cdaffc9 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallHandler.java @@ -0,0 +1,96 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import java.util.Optional; + +/** + * Handler for the `CALL` command. + * + * <p>This handler validates the session, resolves the lobby (by `GAME_ID` when provided or by + * session username otherwise), checks for a running game and forwards the call action to the {@code + * GameController}. + */ +public class PlayerCallHandler extends CommandHandler<PlayerCallRequest> { + private final UserRegistry userRegistry; + private final LobbyManager lobbyManager; + + /** + * Creates a new {@code PlayerCallHandler}. + * + * @param responseDispatcher dispatcher used to send responses back to the client + * @param userRegistry registry to resolve users from session ids + * @param lobbyManager manager used to lookup lobbies and their game controllers + */ + public PlayerCallHandler( + ResponseDispatcher responseDispatcher, + UserRegistry userRegistry, + LobbyManager lobbyManager) { + super(responseDispatcher); + this.userRegistry = userRegistry; + this.lobbyManager = lobbyManager; + } + + /** + * Execute the call request: resolve lobby by `GAME_ID` if present, otherwise by session user, + * verify game is running, and forward the call to the game controller. + * + * @param request the parsed {@link PlayerCallRequest} + */ + @Override + public void execute(PlayerCallRequest request) { + Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt = + userRegistry.getBySessionId(request.getSessionId()); + if (opt.isEmpty()) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in")); + return; + } + + String username = opt.get().getName(); + + Integer gameId = request.getGameId(); + var lobby = + (gameId != null) + ? lobbyManager.getLobby(LobbyId.of(gameId)) + : lobbyManager.getLobbyByUsername(username); + + if (lobby == null) { + if (gameId != null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found")); + } else { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "NOT_IN_LOBBY", "User not in a lobby")); + } + return; + } + + GameController game = lobby.getGameController(); + if (game == null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "GAME_NOT_STARTED", "Game not started")); + return; + } + + try { + game.playerCall(PlayerId.of(username)); + } catch (RuntimeException e) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "GAME_ACTION_FAILED", e.getMessage())); + return; + } + + responseDispatcher.dispatch(new OkResponse(request.getContext())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallParser.java new file mode 100644 index 0000000..dd3ce0d --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallParser.java @@ -0,0 +1,28 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor; + +/** + * Parser for the `CALL` command. + * + * <p>Accepts an optional `GAME_ID` parameter. If provided, the handler will target the specified + * lobby; otherwise the server resolves the lobby by the requesting session's user. + */ +public class PlayerCallParser implements CommandParser<PlayerCallRequest> { + @Override + public PlayerCallRequest parse(PrimitiveRequest primitiveRequest) { + RequestParameterAccessor accessor = + new RequestParameterAccessor(primitiveRequest.parameters()); + + Integer gameId = null; + try { + gameId = accessor.optional("GAME_ID", null, Integer::parseInt); + } catch (Exception e) { + // parse errors handled by framework + } + + return new PlayerCallRequest(primitiveRequest.context(), gameId); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallRequest.java new file mode 100644 index 0000000..c144b0d --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/call/PlayerCallRequest.java @@ -0,0 +1,34 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +/** + * Request for the `CALL` command. + * + * <p>Contains an optional `gameId` when the client targets a specific lobby, otherwise the server + * will resolve the lobby from the requesting session's user. + */ +public class PlayerCallRequest extends Request { + private final Integer gameId; + + /** + * Creates a new {@code PlayerCallRequest}. + * + * @param context the request context + * @param gameId optional game id of the targeted lobby (may be {@code null}) + */ + public PlayerCallRequest(RequestContext context, Integer gameId) { + super(context); + this.gameId = gameId; + } + + /** + * Returns the optional target game id. + * + * @return the game id or {@code null} if not provided + */ + public Integer getGameId() { + return gameId; + } +} From fb5399fc1813a91cf115e1b55fee5651080a1a89 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 01:06:41 +0200 Subject: [PATCH 17/33] Docs: add documentation for call command --- .../networking/commands/protocol-document.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index f29b3dd..574b28f 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -63,6 +63,7 @@ This document describes the protocol for client-server communication in our appl - [Example Request](#example-request) - [Example Response](#example-response) - [RAISE command](#raise-command) + - [CALL command](#call-command) - [BET command](#bet-command) - [SEND_MESSAGE command](#send_message-command) - [Required pre-execution checks](#required-pre-execution-checks) @@ -779,6 +780,61 @@ END END ``` +## CALL command + +The `CALL` command lets the currently logged-in player match the current bet (call) in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | + +### Implementation notes + +- Parser: `PlayerCallParser` — accepts optional `GAME_ID`. +- Handler: `PlayerCallHandler` — validates the session, resolves the lobby by id when provided or by session otherwise and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to call when not their turn | +| `INSUFFICIENT_FUNDS` | Player does not have enough chips | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +CALL GAME_ID=1 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=NOT_YOUR_TURN + MSG=It is not your turn +END +``` + ## GET_LOBBY_STATUS command The `GET_LOBBY_STATUS` command requests the server to return the current state of a lobby, including the list of players and their ready state. From 08e02a6135986c603ed41566293fd3d7e12e98af Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 01:17:14 +0200 Subject: [PATCH 18/33] Feat: add Fold command on server side --- .../dbis/cs108/casono/server/ServerApp.java | 14 +++ .../commands/game/fold/PlayerFoldHandler.java | 96 +++++++++++++++++++ .../commands/game/fold/PlayerFoldParser.java | 28 ++++++ .../commands/game/fold/PlayerFoldRequest.java | 34 +++++++ 4 files changed, 172 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldHandler.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldParser.java create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldRequest.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index eef8e31..5dedaf3 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -210,6 +210,20 @@ public class ServerApp { new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call .PlayerCallHandler(responseDispatcher, userRegistry, lobbyManager)); + // FOLD registration + parserDispatcher.register( + "FOLD", + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold + .PlayerFoldParser()); + commandRouter.register( + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest + .class, + (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold + .PlayerFoldRequest>) + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold + .PlayerFoldHandler(responseDispatcher, userRegistry, lobbyManager)); + // GET_LOBBY_STATUS registration parserDispatcher.register( "GET_LOBBY_STATUS", diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldHandler.java new file mode 100644 index 0000000..0db3628 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldHandler.java @@ -0,0 +1,96 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import java.util.Optional; + +/** + * Handler for the `FOLD` command. + * + * <p>This handler validates the session, resolves the lobby (by `GAME_ID` when provided or by + * session username otherwise), checks for a running game and forwards the fold action to the {@code + * GameController}. + */ +public class PlayerFoldHandler extends CommandHandler<PlayerFoldRequest> { + private final UserRegistry userRegistry; + private final LobbyManager lobbyManager; + + /** + * Creates a new {@code PlayerFoldHandler}. + * + * @param responseDispatcher dispatcher used to send responses back to the client + * @param userRegistry registry to resolve users from session ids + * @param lobbyManager manager used to lookup lobbies and their game controllers + */ + public PlayerFoldHandler( + ResponseDispatcher responseDispatcher, + UserRegistry userRegistry, + LobbyManager lobbyManager) { + super(responseDispatcher); + this.userRegistry = userRegistry; + this.lobbyManager = lobbyManager; + } + + /** + * Execute the fold request: resolve lobby by `GAME_ID` if present, otherwise by session user, + * verify game is running, and forward the fold to the game controller. + * + * @param request the parsed {@link PlayerFoldRequest} + */ + @Override + public void execute(PlayerFoldRequest request) { + Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt = + userRegistry.getBySessionId(request.getSessionId()); + if (opt.isEmpty()) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in")); + return; + } + + String username = opt.get().getName(); + + Integer gameId = request.getGameId(); + var lobby = + (gameId != null) + ? lobbyManager.getLobby(LobbyId.of(gameId)) + : lobbyManager.getLobbyByUsername(username); + + if (lobby == null) { + if (gameId != null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found")); + } else { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "NOT_IN_LOBBY", "User not in a lobby")); + } + return; + } + + GameController game = lobby.getGameController(); + if (game == null) { + responseDispatcher.dispatch( + new ErrorResponse( + request.getContext(), "GAME_NOT_STARTED", "Game not started")); + return; + } + + try { + game.playerFold(PlayerId.of(username)); + } catch (RuntimeException e) { + responseDispatcher.dispatch( + new ErrorResponse(request.getContext(), "GAME_ACTION_FAILED", e.getMessage())); + return; + } + + responseDispatcher.dispatch(new OkResponse(request.getContext())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldParser.java new file mode 100644 index 0000000..1fe8d30 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldParser.java @@ -0,0 +1,28 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor; + +/** + * Parser for the `FOLD` command. + * + * <p>Accepts an optional `GAME_ID` parameter. If provided, the handler will target the specified + * lobby; otherwise the server resolves the lobby by the requesting session's user. + */ +public class PlayerFoldParser implements CommandParser<PlayerFoldRequest> { + @Override + public PlayerFoldRequest parse(PrimitiveRequest primitiveRequest) { + RequestParameterAccessor accessor = + new RequestParameterAccessor(primitiveRequest.parameters()); + + Integer gameId = null; + try { + gameId = accessor.optional("GAME_ID", null, Integer::parseInt); + } catch (Exception e) { + // parse handled by framework + } + + return new PlayerFoldRequest(primitiveRequest.context(), gameId); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldRequest.java new file mode 100644 index 0000000..536aa5f --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/fold/PlayerFoldRequest.java @@ -0,0 +1,34 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; + +/** + * Request for the `FOLD` command. + * + * <p>Contains an optional `gameId` when the client targets a specific lobby, otherwise the server + * will resolve the lobby from the requesting session's user. + */ +public class PlayerFoldRequest extends Request { + private final Integer gameId; + + /** + * Creates a new {@code PlayerFoldRequest}. + * + * @param context the request context + * @param gameId optional game id of the targeted lobby (may be {@code null}) + */ + public PlayerFoldRequest(RequestContext context, Integer gameId) { + super(context); + this.gameId = gameId; + } + + /** + * Returns the optional target game id. + * + * @return the game id or {@code null} if not provided + */ + public Integer getGameId() { + return gameId; + } +} From d0e908774b05a409bae21df7d5e1511414684e9b Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 01:18:24 +0200 Subject: [PATCH 19/33] Docs: Add documentation for fold command --- .../networking/commands/protocol-document.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index 574b28f..2a5aca9 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -64,6 +64,7 @@ This document describes the protocol for client-server communication in our appl - [Example Response](#example-response) - [RAISE command](#raise-command) - [CALL command](#call-command) + - [FOLD command](#fold-command) - [BET command](#bet-command) - [SEND_MESSAGE command](#send_message-command) - [Required pre-execution checks](#required-pre-execution-checks) @@ -835,6 +836,222 @@ END END ``` +## FOLD command + +The `FOLD` command lets the currently logged-in player leave the current hand (fold) in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | + +### Implementation notes + +- Parser: `PlayerFoldParser` — accepts optional `GAME_ID`. +- Handler: `PlayerFoldHandler` — validates the session, resolves the lobby by id when provided or by session otherwise and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to fold when not their turn | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +FOLD GAME_ID=1 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=NOT_YOUR_TURN + MSG=It is not your turn +END +``` + +## FOLD command + +The `FOLD` command lets the currently logged-in player leave the current hand (fold) in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | + +### Implementation notes + +- Parser: `PlayerFoldParser` — accepts optional `GAME_ID`. +- Handler: `PlayerFoldHandler` — validates the session, resolves the lobby by id when provided or by session otherwise and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to fold when not their turn | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +FOLD GAME_ID=1 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=NOT_YOUR_TURN + MSG=It is not your turn +END +``` + +## FOLD command + +The `FOLD` command lets the currently logged-in player leave the current hand (fold) in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | + +### Implementation notes + +- Parser: `PlayerFoldParser` — accepts optional `GAME_ID`. +- Handler: `PlayerFoldHandler` — validates the session, resolves the lobby by id when provided or by session otherwise and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to fold when not their turn | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +FOLD GAME_ID=1 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=NOT_YOUR_TURN + MSG=It is not your turn +END +``` + +## FOLD command + +The `FOLD` command lets the currently logged-in player leave the current hand (fold) in the ongoing game for their lobby. + +### Required pre-execution checks + +- [`UserLoggedInCheck`](#userloggedincheck) + +### Request Parameters + +| Parameter Name | Type | Optional | Description | +| :------------- | :--- | :------: | :---------- | +| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to target. If omitted the server resolves the lobby by the requesting session's user. | + +### Implementation notes + +- Parser: `PlayerFoldParser` — accepts optional `GAME_ID`. +- Handler: `PlayerFoldHandler` — validates the session, resolves the lobby by id when provided or by session otherwise and forwards to `GameController`. + +### Success Response + +No additional response fields. Server replies with `+OK` on success. + +### Error Response + +| Code | Description | +| :--- | :---------- | +| `NOT_YOUR_TURN` | The player attempted to fold when not their turn | +| `GAME_NOT_STARTED` | No game is running in the lobby | +| `NOT_IN_LOBBY` | Requesting user is not a member of the lobby | +| `LOBBY_NOT_FOUND` | The specified `GAME_ID` does not exist | + +### Example Request + +``` +FOLD GAME_ID=1 +``` + +### Example Response (success) + +``` ++OK +END +``` + +### Example Response (error) + +``` +-ERR + CODE=NOT_YOUR_TURN + MSG=It is not your turn +END +``` + ## GET_LOBBY_STATUS command The `GET_LOBBY_STATUS` command requests the server to return the current state of a lobby, including the list of players and their ready state. From c0b149cc5f52b89dba765b57501d141af1154bcc Mon Sep 17 00:00:00 2001 From: jk <julian.kropff@icloud.com> Date: Sun, 12 Apr 2026 09:00:41 +0200 Subject: [PATCH 20/33] Fix: add lobby ui input field style and fix translations Refs #106 --- .../resources/ui-structure/Casinomainui.css | 122 +++++++++++++++--- 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/src/main/resources/ui-structure/Casinomainui.css b/src/main/resources/ui-structure/Casinomainui.css index 8a9dc97..72765de 100644 --- a/src/main/resources/ui-structure/Casinomainui.css +++ b/src/main/resources/ui-structure/Casinomainui.css @@ -1,4 +1,3 @@ -/* Pixel-Art Font (WICHTIG: Nutze eine Monospace-Schrift für Pixel-Look) */ .root { -fx-background-color: #000000; -fx-font-family: "Monospaced", "Courier New"; @@ -6,23 +5,23 @@ .anchor-pane-bg { -fx-background-color: #ffffff; - /* Optional: Bild entfernen oder als Overlay nutzen */ + /* Optional: Remove the image or use it as an overlay */ -fx-background-image: url("/images/background.png"); -fx-background-size: cover; -fx-background-position: center center; -fx-background-repeat: no-repeat; } -/* DIE SCHWEBENDE INFO-BOX (PIXEL-STYLE) */ +/* THE FLOATING INFO BOX */ .floating-chat-box { -fx-background-color: #0d9e3b; - /* Pixel-Ränder: Keine Rundung (0px) oder nur sehr kleine Stufen */ + /* Pixel edges: No rounding (0px) or only very slight steps */ -fx-background-radius: 58; -fx-border-radius: 50; -fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10; -fx-border-width: 12; -fx-padding: 20; - /* Harter Schatten statt weicher Glow */ + /* Hard shadows instead of a soft glow */ -fx-effect: dropshadow(one-pass-box, rgba(0,0,0,1), 0, 0, 15, 15); } @@ -38,20 +37,19 @@ -fx-font-size: 16px; } -/* DER OVALE PIXEL-TISCH */ +/* THE OVAL PIXEL TABLE */ .casino-table { - -fx-background-color: #0d9e3b; /* Feste Farbe statt Gradient für Pixel-Look */ - /* Oval durch festen Radius - Pixel-Art-Ovale sind stufig */ + -fx-background-color: #0d9e3b; /* Solid color instead of a gradient for a pixelated look */ + /* Oval with a fixed radius - Pixel art ovals are jagged */ -fx-background-radius: 2000; -fx-border-radius: 2000; - /* Dicker, eckiger Holzrand */ + /* Thick, square wooden edge */ -fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10; -fx-border-width: 12; } - .table-title { -fx-text-fill: #ffffff; -fx-font-size: 28px; @@ -59,10 +57,98 @@ -fx-effect: dropshadow(one-pass-box, #000, 0, 0, 3, 3); } +.black-input-field { + -fx-background-color: #1a1a1a; + -fx-text-fill: #ffffff; + -fx-font-family: "Courier New"; + -fx-font-weight: bold; + -fx-background-radius: 8; + -fx-border-radius: 8; + -fx-border-color: #444444; + -fx-border-width: 2; + -fx-padding: 5 12; +} +.black-input-field:hover { + -fx-translate-y: -3; + -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); +} +.black-input-field:focused { + -fx-border-color: #ffffff; + -fx-background-color: #000000; +} -/* Hauptcontainer der Statusbox */ +.gray-input-field { + -fx-background-color: #333333; + -fx-text-fill: #ffffff; + -fx-font-family: "Courier New"; + -fx-font-weight: bold; + -fx-background-radius: 8; + -fx-border-radius: 8; + -fx-border-color: #666666; + -fx-color-color: #cccccc; + -fx-border-width: 2; + -fx-padding: 5 12; +} + +.gray-input-field:hover { + -fx-translate-y: -3; + -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); +} + +.gray-input-field:focused { + -fx-border-color: #ffffff; + -fx-background-color: #333333; +} + +.yellow-input-field { + -fx-background-color: #b8860b; + -fx-text-fill: #ffffff; + -fx-prompt-text-fill: #ffffff; + -fx-font-family: "Courier New"; + -fx-font-weight: bold; + -fx-background-radius: 8; + -fx-border-radius: 8; + -fx-border-color: #ffd700; + -fx-border-width: 2; + -fx-padding: 5 12; +} + +.yellow-input-field:hover { + -fx-translate-y: -3; + -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); +} + +.yellow-input-field:focused { + -fx-border-color: #ffffff; + -fx-background-color: #b8860b; +} + +.red-input-field { + -fx-background-color: #8b0000; + -fx-text-fill: #ffffff; + -fx-prompt-text-fill: #ffffff; + -fx-font-family: "Courier New"; + -fx-font-weight: bold; + -fx-background-radius: 8; + -fx-border-radius: 8; + -fx-border-color: #ff4444; + -fx-border-width: 2; + -fx-padding: 5 12; +} + +.red-input-field:hover { + -fx-translate-y: -3; + -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); +} + +.red-input-field:focused { + -fx-border-color: #ffffff; + -fx-background-color: #8b0000; +} + +/* MAIN CONTAINER OF THE STATUS BOX */ .player-status-pane { -fx-background-color: rgb(129, 13, 158); -fx-background-radius: 15; @@ -77,7 +163,7 @@ -fx-translate-y: -3; } -/* Die zwei inneren Boxen */ +/* THE TWO INNER BOXES */ .status-inner-box-top { -fx-background-color: rgb(13, 93, 158); -fx-background-radius: 19; @@ -103,16 +189,16 @@ } .table-title { - /* Titel-Text anpassen */ + /* Customize the title text */ -fx-text-fill: #ffffff; -fx-font-size: 32px; -fx-font-weight: bold; -fx-effect: dropshadow(one-pass-box, #000, 0, 0, 3, 3); - -fx-padding: 10 0 0 0; /* Abstand nach oben */ + -fx-padding: 10 0 0 0; /* Space above */ } .green-box { - /* Grüne Box anpassen */ + /* Customize the green box */ -fx-fill: #4CAF50; -fx-stroke: #FFFFFF; -fx-stroke-width: 3; @@ -133,12 +219,13 @@ -fx-border-color: #666666; -fx-text-fill: #CCCCCC; } + .button-exit:hover { -fx-translate-y: -3; -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); } -/* LOBBY ERSTELLEN BUTTON */ +/* CREATE LOBBY BUTTON */ .button-create-lobby { -fx-background-radius: 12; -fx-border-radius: 12; @@ -151,10 +238,9 @@ -fx-border-color: #2ecc71; -fx-text-fill: #ffffff; } + .button-create-lobby:hover { -fx-translate-y: -3; -fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3); -fx-background-color: #27ae60; } - - From 755ea54381124dab41a074a31af81c0963855d57 Mon Sep 17 00:00:00 2001 From: Julian Kropff <j.kropff@stud.unibas.ch> Date: Sun, 12 Apr 2026 09:05:10 +0200 Subject: [PATCH 21/33] Style: add gray-input-field class to Casino Main UI Refs #106 --- src/main/resources/ui-structure/Casinomainui.fxml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/resources/ui-structure/Casinomainui.fxml b/src/main/resources/ui-structure/Casinomainui.fxml index 214c2a8..98c05c6 100644 --- a/src/main/resources/ui-structure/Casinomainui.fxml +++ b/src/main/resources/ui-structure/Casinomainui.fxml @@ -91,7 +91,10 @@ <!-- place below the 'Lobby erstellen' button --> <Insets top="100" left="20" /> </GridPane.margin> - <TextField fx:id="usernameField" promptText="Benutzername" maxWidth="180" /> + <TextField fx:id="usernameField" + promptText="Username" + styleClass="gray-input-field" + maxWidth="180" /> <Button text="Login" fx:id="loginButton" onAction="#handleLoginButton" From 0227145cab4fedee1fa437fefe46966a0025c228 Mon Sep 17 00:00:00 2001 From: Julian Kropff <j.kropff@stud.unibas.ch> Date: Sun, 12 Apr 2026 09:11:34 +0200 Subject: [PATCH 22/33] Fix: translations in Casino Main UI --- src/main/resources/ui-structure/Casinomainui.fxml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/resources/ui-structure/Casinomainui.fxml b/src/main/resources/ui-structure/Casinomainui.fxml index 98c05c6..d16edc2 100644 --- a/src/main/resources/ui-structure/Casinomainui.fxml +++ b/src/main/resources/ui-structure/Casinomainui.fxml @@ -12,9 +12,9 @@ <GridPane prefWidth="1200" prefHeight="800" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0" AnchorPane.leftAnchor="0" AnchorPane.rightAnchor="0"> <columnConstraints> - <!-- Tisch bekommt 75% vom Fenster --> + <!-- The table takes up 70% of the window --> <ColumnConstraints percentWidth="70.0" hgrow="ALWAYS" /> - <!-- Info-Box bekommt 25% vom Fenster inkl. 5 prozent buffer --> + <!-- The info box takes up 25% of the window, including a 5% buffer --> <ColumnConstraints percentWidth="5.0" hgrow="ALWAYS" /> <ColumnConstraints percentWidth="25.0" hgrow="ALWAYS" /> </columnConstraints> @@ -24,7 +24,7 @@ </rowConstraints> <children> - <!-- LINKER BEREICH (Tisch) --> + <!-- LEFT SIDE (Table) --> <GridPane GridPane.columnIndex="0" style="-fx-background-color: transparent;" alignment="CENTER"> <rowConstraints> <RowConstraints percentHeight="25.0" vgrow="ALWAYS" /> @@ -68,7 +68,7 @@ <Insets top="20" left="20" /> </GridPane.margin> </Button> - <Button text="Lobby erstellen" + <Button text="CREATE A LOBBY" styleClass="button-create-lobby" fx:id="createLobbyButton" onAction="#handleCreateLobbyButton" @@ -88,7 +88,7 @@ GridPane.halignment="LEFT" GridPane.valignment="TOP"> <GridPane.margin> - <!-- place below the 'Lobby erstellen' button --> + <!-- place below the 'CREATE A LOBBY' button --> <Insets top="100" left="20" /> </GridPane.margin> <TextField fx:id="usernameField" @@ -122,10 +122,10 @@ alignment="CENTER" minWidth="100" minHeight="100"> - <!-- Bleibt leer --> + <!-- Remains empty --> </VBox> - <!-- RECHTER BEREICH (Info-Box) --> + <!-- RIGHT SIDE (Chat Box) --> <fx:include source="components/chatui/chatbox.fxml" GridPane.columnIndex="2"/> </children> </GridPane> From 8998152e8d87bcde1aa19314584a3a34c08c32e0 Mon Sep 17 00:00:00 2001 From: Julian Kropff <j.kropff@stud.unibas.ch> Date: Sun, 12 Apr 2026 09:31:25 +0200 Subject: [PATCH 23/33] 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 From 4798de7499b2c5182e1ea64e97d555704816cd0f Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 10:42:56 +0200 Subject: [PATCH 24/33] 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++; } From 0aafe13e34f806bfc77c08730f52fcab08c81e56 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 10:43:43 +0200 Subject: [PATCH 25/33] 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. + * + * <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; + } +} From 0ae88c9149b445d3abe5da362fd1c79a419fabad Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 10:45:03 +0200 Subject: [PATCH 26/33] 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<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); } } From c3e0e740ed33fe2318903dddee3e6c4fab159ecb Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 10:52:22 +0200 Subject: [PATCH 27/33] 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. * - * <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 { 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: <host:port> [username] + * + * <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) { + 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); } } From 65a93a8c8201d19b91a1928958e67cb00473cf63 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 10:52:55 +0200 Subject: [PATCH 28/33] 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()) { From 9635ee8ef9f9a5a3d3965427c7b2d8bcc6e252fa Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 10:56:22 +0200 Subject: [PATCH 29/33] 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); } /** From d16c34844448ff32eb8ad21cb69d4ee0466ff5f9 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 11:14:30 +0200 Subject: [PATCH 30/33] 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: <host:port> [username] + * 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 From a2cd59e6b908e2ff75a008b734da93bf07ef3403 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 11:20:18 +0200 Subject: [PATCH 31/33] Feat: Add SessionDispatcher to send responses by username --- .../dispatcher/SessionDispatcher.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/response/dispatcher/SessionDispatcher.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/response/dispatcher/SessionDispatcher.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/response/dispatcher/SessionDispatcher.java new file mode 100644 index 0000000..ef7e40a --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/response/dispatcher/SessionDispatcher.java @@ -0,0 +1,47 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher; + +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; +import java.util.Optional; + +/** Helper to send responses to sessions identified by usernames. */ +public class SessionDispatcher { + private final ResponseDispatcher responseDispatcher; + private final UserRegistry userRegistry; + + public SessionDispatcher(ResponseDispatcher responseDispatcher, UserRegistry userRegistry) { + this.responseDispatcher = responseDispatcher; + this.userRegistry = userRegistry; + } + + /** + * Dispatch a success response built from {@code body} to the user with {@code username}. If the + * user is disconnected or unknown, the call is a no-op. + */ + public void dispatchToUser(String username, ResponseBody body) { + Optional<User> opt = userRegistry.getByUsername(username); + if (opt.isEmpty()) { + return; + } + User user = opt.get(); + Optional<SessionId> sid = user.getSessionId(); + if (sid.isEmpty()) { + return; + } + RequestContext ctx = new RequestContext(sid.get(), 0); + SuccessResponse resp = new SuccessResponse(ctx, body) {}; + responseDispatcher.dispatch(resp); + } + + /** Broadcast the provided {@code body} to every player in the given lobby. */ + public void broadcastToLobby(Lobby lobby, ResponseBody body) { + for (String username : lobby.getPlayerNames()) { + dispatchToUser(username, body); + } + } +} From e6c222388b9fce54934f85341a2231bea5f49b30 Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 12:03:07 +0200 Subject: [PATCH 32/33] Fix: add correct parsing on client side for create_lobyb command --- .../casono/client/network/LobbyClient.java | 21 +++++++++++++++++-- 1 file changed, 19 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 6d43f19..9f6f4a8 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 @@ -41,8 +41,25 @@ public class LobbyClient { * @return The id of the newly created lobby, as returned by the server. */ public int createLobby() { - String response = client.processCommand("CREATE_LOBBY").getFirst(); - return Integer.parseInt(response); + List<String> lines = client.processCommand("CREATE_LOBBY"); + + List<RequestParameter> params = ClientService.convertToRequestParameters(lines); + for (RequestParameter p : params) { + if ("LOBBY_ID".equalsIgnoreCase(p.key())) { + return Integer.parseInt(p.value()); + } + } + + // Fallback for simple legacy/test servers that return the id as a single plain + // line + if (!lines.isEmpty()) { + try { + return Integer.parseInt(lines.get(0)); + } catch (NumberFormatException ignored) { + } + } + + throw new RuntimeException("No LOBBY_ID in response: " + lines); } /** From fa8bb25b0387f879bc04f34bc411c0f5ffae42ad Mon Sep 17 00:00:00 2001 From: Jona Walpert <jona.walpert@stud.unibas.ch> Date: Sun, 12 Apr 2026 12:03:41 +0200 Subject: [PATCH 33/33] Add: Register create_lobby command --- .../dmi/dbis/cs108/casono/server/ServerApp.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index 5dedaf3..b693217 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -239,6 +239,20 @@ public class ServerApp { .get_lobby_status.GetLobbyStatusHandler( responseDispatcher, lobbyManager, userRegistry)); + // CREATE_LOBBY registration + parserDispatcher.register( + "CREATE_LOBBY", + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby + .CreateLobbyParser()); + commandRouter.register( + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby + .CreateLobbyRequest.class, + (ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< + ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby + .create_lobby.CreateLobbyRequest>) + new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby + .CreateLobbyHandler(responseDispatcher, lobbyManager)); + // JOIN_LOBBY registration parserDispatcher.register( "JOIN_LOBBY",