Merge branch 'main' into feat/100-add-start-game-command-on-server-side

This commit is contained in:
Jona Walpert
2026-04-12 12:45:24 +02:00
34 changed files with 1846 additions and 120 deletions
@@ -7,6 +7,12 @@ import org.apache.logging.log4j.Logger;
/** Main entry point for Casono application. Handles client and server startup. */
public final class Main {
private static final int MIN_ARGS_FOR_USERNAME = 3;
private static final int ARGS_COUNT_SERVER = 2;
private static final int ARGS_COUNT_CLIENT_MIN = 2;
private static final int ARGS_COUNT_CLIENT_WITH_USER = 3;
/**
* Main entry point for Casono.
*
@@ -17,10 +23,13 @@ public final class Main {
printUsage();
System.exit(1);
}
switch (args[0]) {
case "server" -> ServerApp.start(args[1]);
case "client" -> ClientApp.start(args[1]);
case "client" -> {
String address = args[1];
String username = args.length >= MIN_ARGS_FOR_USERNAME ? args[2] : null;
ClientApp.start(address, username);
}
default -> {
printUsage();
System.exit(1);
@@ -29,12 +38,15 @@ public final class Main {
}
private static boolean isValid(String[] args) {
if (args.length != 2) {
if (args.length < ARGS_COUNT_SERVER) {
return false;
}
return switch (args[0]) {
case "server", "client" -> true;
case "server" -> args.length == ARGS_COUNT_SERVER;
case "client" ->
args.length == ARGS_COUNT_CLIENT_MIN
|| args.length == ARGS_COUNT_CLIENT_WITH_USER;
default -> false;
};
}
@@ -45,7 +57,7 @@ public final class Main {
"""
Usage:
java -jar xyz.jar server <listenPort>
java -jar xyz.jar client <serverIp>:<serverPort>
java -jar xyz.jar client <serverIp>:<serverPort> [<username>]
""");
}
}
@@ -1,33 +1,51 @@
package ch.unibas.dmi.dbis.cs108.casono.client;
/**
* 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.
*
* @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 <ip>:<port>.");
@@ -36,16 +54,69 @@ public class ClientApp {
int port = Integer.parseInt(parts[1]);
LOGGER.info("You've selected the client. It will connect port {} at host {}", port, host);
// Expose the chosen host/port to the UI via system properties so controllers
// (which read System.getProperty("casono.server.host"/"casono.server.port"))
// can obtain the correct connection information.
System.setProperty("casono.server.host", host);
System.setProperty("casono.server.port", Integer.toString(port));
// Forward the original address argument to the launcher as well.
Launcher.main(new String[] {arg});
System.setProperty("casono.server.port", String.valueOf(port));
// If a username was provided, create a shared connection and perform
// the initial LOGIN asynchronously on that connection. The connection
// is intentionally NOT closed so the UI can reuse it.
if (username != null && !username.isBlank()) {
try {
ClientService clientService = new ClientService(host, port);
setSharedClientService(clientService);
java.util.concurrent.ExecutorService bg =
java.util.concurrent.Executors.newSingleThreadExecutor(
r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("casono-startup-login");
return t;
});
bg.submit(
() -> {
try {
LobbyClient lobbyClient = new LobbyClient(clientService);
LoginResult res = lobbyClient.login(username);
LOGGER.info(
"Assigned username='{}' id={}",
res.getUsername(),
res.getId());
} catch (RuntimeException e) {
LOGGER.warn("Startup login failed: {}", e.getMessage());
} catch (Exception e) {
LOGGER.warn("Unexpected error during startup login", e);
} finally {
bg.shutdown();
}
});
} catch (RuntimeException e) {
LOGGER.warn(
"Could not establish initial connection for startup login: {}",
e.getMessage());
// UI will create its own connection when it initializes
}
}
if (username != null && !username.isBlank()) {
Launcher.main(new String[] {arg, username});
} else {
Launcher.main(new String[] {arg});
}
}
/**
* Main entry point. {@code <host:port>} (e.g. {@code 127.0.0.1:1234})
*
* <p>If a username is provided the application attempts a startup LOGIN on the shared
* connection; the UI will still start and will reuse the connection when possible. The username
* is not propagated via a system property for UI display.
*/
public static void main(String[] args) {
start(args[0]);
if (args == null || args.length == 0) {
throw new IllegalArgumentException("Address argument required: <host:port>");
}
// Optional username argument
String username = args.length > 1 && args[1] != null && !args[1].isBlank() ? args[1] : null;
start(args[0], username);
}
}
@@ -138,33 +138,64 @@ public class ClientService {
() -> {
try {
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);
}
/**
@@ -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
@@ -38,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);
}
/**
@@ -65,8 +85,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);
}
}
@@ -0,0 +1,38 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
/**
* Result of a successful login request.
*
* <p>Holds the username assigned by the server and the server-side id as a string (UUID). The
* client previously attempted to parse the id as an integer which failed when the server returned a
* UUID; this class therefore stores the id as a {@link String}.
*/
public class LoginResult {
private final String username;
private final String id;
/**
* Create a login result.
*
* @param username the assigned username
* @param id the assigned id (UUID string) returned by the server
*/
public LoginResult(String username, String id) {
this.username = username;
this.id = id;
}
/**
* @return the assigned username
*/
public String getUsername() {
return username;
}
/**
* @return the assigned id as returned by the server (UUID string)
*/
public String getId() {
return id;
}
}
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
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()) {
@@ -169,6 +169,61 @@ 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));
// 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));
// 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));
// 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",
@@ -184,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",
@@ -0,0 +1,108 @@
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;
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;
}
GameController 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()));
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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()));
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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()));
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -0,0 +1,110 @@
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()));
}
}
@@ -0,0 +1,34 @@
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);
}
}
@@ -0,0 +1,45 @@
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;
}
}
@@ -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<CreateLobbyRequest> {
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()));
}
}
@@ -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<CreateLobbyRequest> {
@Override
public CreateLobbyRequest parse(PrimitiveRequest primitiveRequest) {
return new CreateLobbyRequest(primitiveRequest.context());
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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.
*
@@ -13,7 +13,7 @@ import java.util.concurrent.ConcurrentLinkedQueue;
/** Represents an authenticated user on the server. */
public class User {
private final UserId id;
private final String name;
private String name;
private SessionId sessionId;
private Instant disconnectedAt;
private final Queue<Message> messages;
@@ -47,10 +47,20 @@ public class User {
*
* @return the user name
*/
public String getName() {
public synchronized String getName() {
return name;
}
/**
* Sets a new display name for this user. Thread-safe; callers must ensure the registry is
* updated to maintain uniqueness when needed.
*
* @param newName the new display name
*/
public synchronized void setName(String newName) {
this.name = newName;
}
/**
* Returns the session currently associated with this user, if any.
*
@@ -1,10 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/** Creates new users, resolving name conflicts automatically. */
public class UserFactory {
private final UserRegistry registry;
private final AtomicInteger anonymousCounter = new AtomicInteger(1);
private static final Logger LOGGER = LogManager.getLogger(UserFactory.class);
/**
* Creates a new UserFactory backed by the given registry.
@@ -18,16 +23,40 @@ public class UserFactory {
/**
* Creates and registers a new user with the given name and session. If the name is already
* taken, a numeric suffix is appended and incremented until a free name is found (e.g.
* Lars_001, Lars_002, ...).
* Lars_001, Lars_002, ...). If the desiredName is null or empty, an automatic name of the form
* "playerN" is assigned, incrementing N until a free name is found.
*
* @param desiredName the preferred display name
* @param sessionId the session to associate with the new user
* @param sessionId the session to associate with the new userwas
* @return the newly created and registered user
*/
public User create(String desiredName, SessionId sessionId) {
if (desiredName == null || desiredName.isBlank()) {
// assign playerN names for anonymous logins
while (true) {
String candidate = "player" + anonymousCounter.getAndIncrement();
var result = registry.registerIfAvailable(candidate, sessionId);
if (result.isPresent()) {
var user = result.get();
LOGGER.info(
"Registered user '{}' with id {} for session {}",
user.getName(),
user.getId().value(),
sessionId == null ? "null" : sessionId.value());
return user;
}
}
}
var result = registry.registerIfAvailable(desiredName, sessionId);
if (result.isPresent()) {
return result.get();
var user = result.get();
LOGGER.info(
"Registered user '{}' with id {} for session {}",
user.getName(),
user.getId().value(),
sessionId == null ? "null" : sessionId.value());
return user;
}
int suffix = 1;
@@ -35,7 +64,13 @@ public class UserFactory {
String candidate = desiredName + "_" + String.format("%03d", suffix);
result = registry.registerIfAvailable(candidate, sessionId);
if (result.isPresent()) {
return result.get();
var user = result.get();
LOGGER.info(
"Registered user '{}' with id {} for session {}",
user.getName(),
user.getId().value(),
sessionId == null ? "null" : sessionId.value());
return user;
}
suffix++;
}
@@ -185,4 +185,29 @@ public class UserRegistry {
public Collection<User> 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;
}
}
@@ -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);
}
}
}
+104 -18
View File
@@ -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;
}
@@ -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,10 +88,13 @@
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" promptText="Benutzername" maxWidth="180" />
<TextField fx:id="usernameField"
promptText="Username"
styleClass="gray-input-field"
maxWidth="180" />
<Button text="Login"
fx:id="loginButton"
onAction="#handleLoginButton"
@@ -119,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>
@@ -0,0 +1,64 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
import static org.junit.jupiter.api.Assertions.*;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
import org.junit.jupiter.api.Test;
/** Tests for {@link UserFactory} and {@link UserRegistry} name assignment behavior. */
public class UserFactoryTest {
// Logger removed as per patch requirement
@Test
public void createUsersWithDuplicateDesiredNamesAssignsUniqueNames() {
UserRegistry registry = new UserRegistry();
UserFactory factory = new UserFactory(registry);
SessionId s1 = new SessionId();
SessionId s2 = new SessionId();
SessionId s3 = new SessionId();
// Create first user with desired name "Alice"
User u1 = factory.create("Alice", s1);
// Create second user requesting the same name
User u2 = factory.create("Alice", s2);
// Create third user requesting the same name again
User u3 = factory.create("Alice", s3);
assertNotNull(u1);
assertNotNull(u2);
assertNotNull(u3);
// All assigned names must be distinct
String n1 = u1.getName();
String n2 = u2.getName();
String n3 = u3.getName();
// assigned names: n1, n2, n3
assertNotEquals(n1, n2);
assertNotEquals(n1, n3);
assertNotEquals(n2, n3);
// Registry should contain all assigned names
assertTrue(registry.getByUsername(n1).isPresent());
assertTrue(registry.getByUsername(n2).isPresent());
assertTrue(registry.getByUsername(n3).isPresent());
}
@Test
public void createUserWithNullDesiredAssignsPlayerN() {
UserRegistry registry = new UserRegistry();
UserFactory factory = new UserFactory(registry);
SessionId s1 = new SessionId();
User u = factory.create(null, s1);
assertNotNull(u);
String name = u.getName();
// assigned anonymous name: name
assertTrue(name.startsWith("player"), "expected automatic name starting with 'player'");
assertTrue(registry.getByUsername(name).isPresent());
}
}