Merge branch 'main' into feat/93-add-serverside-create-lobby-command

This commit is contained in:
Jona Walpert
2026-04-12 11:27:11 +02:00
159 changed files with 19963 additions and 304 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);
}
}
@@ -2,6 +2,53 @@ package ch.unibas.dmi.dbis.cs108.casono.client.game;
/** Represents a playing card with a value and suit. */
public class Card {
public String value;
public String suit;
private String value;
private String suit;
/**
* Constructs a Card with the specified value and suit.
*
* @param value The value of the card (e.g., 2, 3, ..., 10, J, Q, K, A).
* @param suit The suit of the card (e.g., Hearts, Diamonds, Clubs, Spades).
*/
public Card(String value, String suit) {
this.value = value;
this.suit = suit;
}
/**
* Retrieves the value of the card.
*
* @return The value of the card.
*/
public String getValue() {
return value;
}
/**
* Retrieves the suit of the card.
*
* @return The suit of the card.
*/
public String getSuit() {
return suit;
}
/**
* Sets the value of the card.
*
* @param value The value to set for the card.
*/
public void setValue(String value) {
this.value = value;
}
/**
* Sets the suit of the card.
*
* @param suit The suit to set for the card.
*/
public void setSuit(String suit) {
this.suit = suit;
}
}
@@ -0,0 +1,107 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
import ch.unibas.dmi.dbis.cs108.casono.client.network.GameClient;
import java.util.List;
/**
* Service class responsible for managing the game state and providing methods to interact with the
* game.
*/
public class GameService {
private final GameClient client;
private GameState state;
/**
* Constructs a GameService with the given GameClient for communication with the server.
*
* @param client The GameClient instance used to send commands and receive responses from the
* server.
*/
public GameService(GameClient client) {
this.client = client;
}
/**
* Refresh the game state by fetching the latest state from the server using the GameClient.
*
* @return The updated GameState object representing the current state of the game after
* refreshing.
*/
public GameState refresh() {
state = client.fetchGameState();
return state;
}
/**
* Get the current game state.
*
* @return The current GameState object representing the state of the game.
*/
public int getPot() {
return state.pot;
}
/**
* Get the current game state.
*
* @return The current GameState object representing the state of the game.
*/
public List<Card> getCommunityCards() {
return state.communityCards;
}
/**
* Get the list of players in the current game state.
*
* @return A list of Player objects representing the players in the current game state.
*/
public List<Player> getPlayers() {
return state.players;
}
/** Send a CALL command to the server to indicate that the player wants to call. */
public void call() {
client.sendCall();
}
/** Send a FOLD command to the server to indicate that the player wants to fold. */
public void fold() {
client.sendFold();
}
/**
* Send a BET command to the server to indicate that the player wants to bet with the specified
* amount.
*
* @param amount The amount the player wants to bet.
*/
public void bet(int amount) {
client.sendBet(amount);
}
/**
* Send a RAISE command to the server to indicate that the player wants to raise to the
* specified amount.
*
* @param amount The amount the player wants to raise to.
*/
public void raise(int amount) {
client.sendRaise(amount);
}
/**
* Get the winner of the game if there is one. If the game is still ongoing or if there is no
* winner, this method returns null.
*
* @return The Player object representing the winner of the game, or null if there is no winner
* yet.
*/
public Player getWinner() {
if (state.winnerIndex < 0) {
return null;
}
return state.players.get(state.winnerIndex);
}
}
@@ -13,6 +13,7 @@ public class GameState {
public int currentBet;
public int dealer;
public int activePlayer;
public int winnerIndex = -1;
public List<Card> communityCards = new ArrayList<>();
public List<Player> players = new ArrayList<>();
@@ -8,10 +8,186 @@ import java.util.List;
* (e.g., "active", "folded"), and their hole cards.
*/
public class Player {
public String name;
public int chips;
public int bet;
public String state;
public List<Card> cards = new ArrayList<>();
private PlayerId id;
private int chips;
private int bet;
private PlayerState state;
private List<Card> cards = new ArrayList<>();
/**
* Constructs a new Player with default values. The player's state is set to ACTIVE by default.
*/
public Player() {
this.state = PlayerState.ACTIVE;
}
/**
* Constructs a new Player with the specified ID and initial chip count. The player's state is
* set to ACTIVE by default.
*
* @param id The unique identifier for the player.
* @param chips The initial number of chips the player has.
*/
public Player(PlayerId id, int chips) {
this.id = id;
this.chips = chips;
this.bet = 0;
this.state = PlayerState.ACTIVE;
}
/**
* Retrieves the unique identifier of the player.
*
* @return The player's ID.
*/
public PlayerId getId() {
return id;
}
/**
* Sets the unique identifier of the player.
*
* @param id The player's ID to set.
*/
public void setId(PlayerId id) {
this.id = id;
}
/**
* Returns the display name of the player. If the player's ID is not set, it returns an empty
* string.
*
* @return The player's name.
*/
public String getName() {
return id != null ? id.value() : "";
}
/**
* Retrieves the current chip count of the player.
*
* @return The number of chips the player has.
*/
public int getChips() {
return chips;
}
/**
* Sets the current chip count of the player.
*
* @param chips The number of chips to set for the player.
*/
public void setChips(int chips) {
this.chips = chips;
}
/**
* Retrieves the current bet amount of the player.
*
* @return The amount the player has currently bet in the ongoing hand.
*/
public int getBet() {
return bet;
}
/**
* Sets the current bet amount of the player.
*
* @param bet The amount the player has currently bet in the ongoing hand to set.
*/
public void setBet(int bet) {
this.bet = bet;
}
/**
* Retrieves the current state of the player (e.g., ACTIVE, FOLDED, ALL_IN).
*
* @return The player's current state in the game.
*/
public PlayerState getState() {
return state;
}
/**
* Sets the current state of the player (e.g., ACTIVE, FOLDED, ALL_IN).
*
* @param state The player's current state in the game to set.
*/
public void setState(PlayerState state) {
this.state = state;
}
/**
* Checks if the player is currently the dealer.
*
* @return True if the player's state is DEALER, false otherwise.
*/
public boolean isDealer() {
return state == PlayerState.DEALER;
}
/**
* Retrieves the list of hole cards currently held by the player.
*
* @return A list of Card objects representing the player's hole cards.
*/
public List<Card> getCards() {
return cards;
}
/**
* Sets the list of hole cards currently held by the player.
*
* @param cards A list of Card objects representing the player's hole cards to set.
*/
public void setCards(List<Card> cards) {
this.cards = cards;
}
/**
* Adds a card to the player's list of hole cards.
*
* @param card The Card object to be added to the player's hole cards.
*/
public void addCard(Card card) {
this.cards.add(card);
}
/**
* Adds the specified amount of chips to the player's current chip count.
*
* @param amount The number of chips to add to the player's current chip count.
*/
public void addChips(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
this.chips += amount;
}
/**
* Removes the specified amount of chips from the player's current chip count.
*
* @param amount The number of chips to remove from the player's current chip count.
*/
public void removeChips(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
if (amount > this.chips) {
throw new IllegalArgumentException(
"Not enough chips. Available: " + this.chips + ", Requested: " + amount);
}
this.chips -= amount;
}
/** Sets the player's state to FOLDED, indicating that they have folded in the current hand. */
public void fall() {
this.state = PlayerState.FOLDED;
if (this.id != null) {
this.id = PlayerId.of(this.id.value() + " (Fall)");
}
}
}
@@ -0,0 +1,66 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
import java.util.Objects;
/** Represents a unique identifier for a player in the client domain. */
public record PlayerId(String value) {
/**
* Constructs a PlayerId with the given string value, ensuring it is not null or empty.
*
* @param value the string value representing the player's unique identifier.
*/
public PlayerId {
Objects.requireNonNull(value, "PlayerId cannot be null");
value = value.trim();
if (value.isEmpty()) {
throw new IllegalArgumentException("PlayerId cannot be empty");
}
}
/** Factory method to create a PlayerId. */
public static PlayerId of(String value) {
return new PlayerId(value);
}
/**
* Returns the string representation of the PlayerId, which is the encapsulated value.
*
* @return the string value of the PlayerId.
*/
@Override
public String toString() {
return value;
}
/**
* Compares this PlayerId with another object for equality.
*
* @param o the object to compare to.
* @return true if this PlayerId is equal to the given object, false otherwise.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PlayerId playerId = (PlayerId) o;
return Objects.equals(value, playerId.value);
}
/**
* Returns the hash code for this PlayerId.
*
* @return the hash code of the PlayerId.
*/
@Override
public int hashCode() {
return Objects.hash(value);
}
}
@@ -0,0 +1,8 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
/** Enumeration representing the possible states of a player in the poker game. */
public enum PlayerState {
ACTIVE,
FOLDED,
DEALER
}
@@ -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);
}
/**
@@ -3,6 +3,8 @@ package ch.unibas.dmi.dbis.cs108.casono.client.network;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Card;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import java.util.List;
/**
@@ -13,6 +15,7 @@ import java.util.List;
public class GameClient {
private final ClientService client;
private final int gameId;
/**
* Constructs a GameClient with the given ClientService for communication.
@@ -20,80 +23,290 @@ public class GameClient {
* @param client The ClientService instance used to send commands and receive responses from the
* server.
*/
public GameClient(ClientService client) {
public GameClient(ClientService client, int gameId) {
this.client = client;
this.gameId = gameId;
}
/**
* Retrieves the current game state from the server by sending a command and parsing the
* response.
* Fetch the current game state from the server by sending a "GET_GAME_STATE"
*
* @return A GameState object representing the current state of the game.
* @return A GameState object representing the current state of the game, as parsed
*/
public GameState getGameState() {
List<String> response = client.processCommand("GET_GAME_STATE");
return parseGameState(response);
public GameState fetchGameState() {
List<String> responseLines = client.processCommand("GET_GAME_STATE\nGAME_ID=" + gameId);
if (responseLines == null || responseLines.isEmpty()) {
throw new RuntimeException("Empty server response");
}
String fullResponse = String.join("\n", responseLines);
return parse(fullResponse);
}
/** Send a CALL command to the server to indicate that the player wants to call */
public void sendCall() {
client.processCommand("CALL\nGAME_ID=" + gameId);
}
/** Send a FOLD command to the server to indicate that the player wants to fold */
public void sendFold() {
client.processCommand("FOLD\nGAME_ID=" + gameId);
}
/** Send a BET command to the server to indicate that the player wants to bet */
public void sendBet(int amount) {
client.processCommand("BET\nGAME_ID=" + gameId + "\nAMOUNT=" + amount);
}
/**
* Parses the raw response from the server into a structured GameState object.
* Send a RAISE command to the server to indicate that the player wants to raise
*
* @param input The raw response string from the server.
* @param amount The amount to raise to
*/
public void sendRaise(int amount) {
client.processCommand("RAISE\nGAME_ID=" + gameId + "\nAMOUNT=" + amount);
}
/**
* Parses the raw server response string into a structured GameState object.
*
* @param input The raw server response string containing the game state information.
* @return A GameState object representing the current state of the game.
*/
private GameState parseGameState(List<String> input) {
private GameState parse(String input) {
GameState state = new GameState();
// String[] lines = input.split("\n");
ParserContext ctx = new ParserContext(state);
Player currentPlayer = null;
Card currentCard = null;
for (String raw : input.split("\n")) {
String line = raw.trim();
for (String rawLine : input) {
String line = rawLine.trim();
if (line.startsWith("+OK") || line.equals("END")) {
if (shouldSkip(line)) {
continue;
}
if (line.startsWith("PHASE=")) {
state.phase = line.split("=")[1];
} else if (line.startsWith("POT=")) {
state.pot = Integer.parseInt(line.split("=")[1]);
} else if (line.startsWith("CURRENT_BET=")) {
state.currentBet = Integer.parseInt(line.split("=")[1]);
} else if (line.startsWith("DEALER=")) {
state.dealer = Integer.parseInt(line.split("=")[1]);
} else if (line.startsWith("ACTIVE_PLAYER=")) {
state.activePlayer = Integer.parseInt(line.split("=")[1]);
} else if (line.startsWith("PLAYER")) {
currentPlayer = new Player();
state.players.add(currentPlayer);
} else if (line.startsWith("NAME=") && currentPlayer != null) {
currentPlayer.name = line.split("=")[1];
} else if (line.startsWith("CHIPS=") && currentPlayer != null) {
currentPlayer.chips = Integer.parseInt(line.split("=")[1]);
} else if (line.startsWith("BET=") && currentPlayer != null) {
currentPlayer.bet = Integer.parseInt(line.split("=")[1]);
} else if (line.startsWith("STATE=") && currentPlayer != null) {
currentPlayer.state = line.split("=")[1];
} else if (line.startsWith("CARD")) {
currentCard = new Card();
if (handleGlobal(line, ctx)) {
continue;
}
if (currentPlayer != null) {
currentPlayer.cards.add(currentCard);
} else {
state.communityCards.add(currentCard);
}
} else if (line.startsWith("VALUE=") && currentCard != null) {
currentCard.value = line.split("=")[1];
} else if (line.startsWith("SUIT=") && currentCard != null) {
currentCard.suit = line.split("=")[1];
if (handleSections(line, ctx)) {
continue;
}
if (handlePlayer(line, ctx)) {
continue;
}
if (handleCards(line, ctx)) {
continue;
}
}
return state;
}
/** Holds the mutable parsing state while processing the server response. */
private static class ParserContext {
GameState state;
Player currentPlayer;
Card currentCard;
boolean inPlayers;
boolean inPlayerCards;
boolean inCommunityCards;
ParserContext(GameState state) {
this.state = state;
}
}
/**
* Checks whether a line should be ignored.
*
* @param line the current input line
* @return true if the line is empty or a status message, false otherwise
*/
private boolean shouldSkip(String line) {
return line.isEmpty() || line.startsWith("+OK");
}
/**
* Parses global game state properties (e.g., phase, pot, current bet).
*
* @param line the current input line
* @param ctx the parser context containing the game state
* @return true if the line was handled, false otherwise
*/
private boolean handleGlobal(String line, ParserContext ctx) {
GameState state = ctx.state;
if (line.startsWith("PHASE=")) {
state.phase = value(line);
} else if (line.startsWith("POT=")) {
state.pot = intVal(line);
} else if (line.startsWith("CURRENT_BET=")) {
state.currentBet = intVal(line);
} else if (line.startsWith("DEALER=")) {
state.dealer = intVal(line);
} else if (line.startsWith("ACTIVE_PLAYER=")) {
state.activePlayer = intVal(line);
} else if (line.startsWith("WINNER=")) {
state.winnerIndex = intVal(line);
} else {
return false;
}
return true;
}
/**
* Processes section markers such as PLAYERS, PLAYER, CARDS, and END. Updates the parser context
* accordingly.
*
* @param line the current input line
* @param ctx the parser context
* @return true if the line was handled, false otherwise
*/
private boolean handleSections(String line, ParserContext ctx) {
if (line.equals("END")) {
ctx.inPlayers = false;
ctx.inPlayerCards = false;
ctx.inCommunityCards = false;
ctx.currentPlayer = null;
ctx.currentCard = null;
return true;
}
if (line.equals("PLAYERS")) {
ctx.inPlayers = true;
return true;
}
if (line.equals("PLAYER")) {
ctx.currentPlayer = new Player();
ctx.state.players.add(ctx.currentPlayer);
return true;
}
if (line.equals("CARDS")) {
if (ctx.inPlayers && ctx.currentPlayer != null) {
ctx.inPlayerCards = true;
ctx.inCommunityCards = false;
} else {
ctx.inCommunityCards = true;
ctx.inPlayerCards = false;
}
return true;
}
return false;
}
/**
* Parses properties of the current player (e.g., name, chips, bet, state).
*
* @param line the current input line
* @param ctx the parser context
* @return true if the line was handled, false otherwise
*/
private boolean handlePlayer(String line, ParserContext ctx) {
Player p = ctx.currentPlayer;
if (p == null) {
return false;
}
if (line.startsWith("NAME=")) {
p.setId(PlayerId.of(value(line)));
} else if (line.startsWith("CHIPS=")) {
p.setChips(intVal(line));
} else if (line.startsWith("BET=")) {
p.setBet(intVal(line));
} else if (line.startsWith("STATE=")) {
p.setState(parseState(value(line)));
} else {
return false;
}
return true;
}
/**
* Parses card-related lines and assigns cards to the current player or the community cards.
*
* @param line the current input line
* @param ctx the parser context
* @return true if the line was handled, false otherwise
*/
private boolean handleCards(String line, ParserContext ctx) {
if (line.startsWith("CARD")) {
ctx.currentCard = new Card("", "");
if (ctx.inPlayerCards && ctx.currentPlayer != null) {
ctx.currentPlayer.addCard(ctx.currentCard);
} else if (ctx.inCommunityCards) {
ctx.state.communityCards.add(ctx.currentCard);
}
return true;
}
if (line.startsWith("VALUE=") && ctx.currentCard != null) {
ctx.currentCard.setValue(value(line));
return true;
}
if (line.startsWith("SUIT=") && ctx.currentCard != null) {
ctx.currentCard.setSuit(value(line));
return true;
}
return false;
}
/**
* Helper method to extract the value from a line in the format KEY=VALUE.
*
* @param line The input line from which to extract the value, expected to be in the format
* KEY=VALUE.
* @return The extracted value part of the input line, which is the substring after the first
* '=' character.
*/
private String value(String line) {
return line.split("=", 2)[1];
}
/**
* Helper method to extract an integer value from a line in the format KEY=VALUE.
*
* @param line The input line from which to extract the integer value, expected to be in the
* format KEY=VALUE where VALUE is an integer.
* @return The extracted integer value from the input line, parsed from the substring after the
* first '=' character.
*/
private int intVal(String line) {
return Integer.parseInt(value(line));
}
/**
* Helper method to parse a string representation of a player's state into the corresponding
* PlayerState enum value.
*
* @param s The input string representing the player's state, expected to be one of ACTIVE,
* FOLDED, or DEALER (case-insensitive).
* @return The corresponding PlayerState enum value based on the input string. If the input does
* not match any known state, it defaults to PlayerState.ACTIVE.
*/
private PlayerState parseState(String s) {
return switch (s.toUpperCase()) {
case "ACTIVE" -> PlayerState.ACTIVE;
case "FOLDED" -> PlayerState.FOLDED;
case "DEALER" -> PlayerState.DEALER;
default -> PlayerState.ACTIVE;
};
}
}
@@ -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);
}
}
@@ -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,7 +1,19 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Card;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController;
import java.util.List;
import java.util.logging.Logger;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
@@ -15,24 +27,853 @@ import javafx.scene.layout.VBox;
*/
public class CasinoGameController {
private static final Logger LOGGER = Logger.getLogger(CasinoGameController.class.getName());
@FXML private Label tableText;
@FXML private VBox casinoTable;
@FXML private HBox communityCardsBox;
@FXML private VBox casinoTableInnerBox;
@FXML private HBox playerCardsBox;
@FXML private HBox potBox;
@FXML private PlayerStatusController playerStatusController;
@FXML private PlayerStatusController player1Controller;
@FXML private PlayerStatusController player2Controller;
@FXML private PlayerStatusController player3Controller;
@FXML private VBox player1;
@FXML private VBox player2;
@FXML private VBox player3;
@FXML private ImageView myDealerIcon;
private GameService gameService;
private PlayerId myPlayerId;
private TaskbarController taskbarController;
private Image dealerImage;
private static final int TOTAL_SLOTS = 5;
private static final int PLAYER_SLOTS = 2;
private int pot = 0;
private static final String BACKSIDE = "/images/card-background-3.png";
private static final String DEALER_IMAGE_PATH = "/images/chip-dealer-blue-3.png";
private static final double TABLE_OFFSET_Y_FACTOR = 0.09;
private static final double PLAYER_CARDS_OFFSET_Y_FACTOR = 0.35;
private static final double MOVE_FROM_X = -40;
private static final double MOVE_FROM_Y = -10;
private static final double MOVE_TO_X = 0;
private static final double MOVE_TO_Y = 0;
private static final double FADE_FROM = 0;
private static final double FADE_TO = 1;
private static final double SCALE_FROM = 0.95;
private static final double SCALE_TO = 1;
private static final double SETTLE_FROM_Y = 0;
private static final double SETTLE_TO_Y = 10;
private static final boolean SETTLE_AUTO_REVERSE = true;
private static final int SETTLE_CYCLE_COUNT = 2;
private static final long ANIMATION_DURATION_MS = 350;
private static final long SETTLE_DURATION_MS = 150;
private static final long STAGGER_DELAY_MS = 120;
private static final double HOVER_SCALE_ON = 1.08;
private static final double HOVER_SCALE_OFF = 1.0;
private static final double HOVER_LIFT_ON = -8;
private static final double HOVER_LIFT_OFF = 0;
private static final long HOVER_DURATION_MS = 180;
private static final long UI_UPDATE_INTERVAL_SECONDS = 1;
private static final int PLAYER_INDEX_0 = 0;
private static final int PLAYER_INDEX_1 = 1;
private static final int PLAYER_INDEX_2 = 2;
private static final int DEALER_INDEX_PLAYER_1 = 0;
private static final int DEALER_INDEX_PLAYER_2 = 1;
private static final int DEALER_INDEX_PLAYER_3 = 2;
private static final int DEALER_PLAYER_1 = 0;
private static final int DEALER_PLAYER_2 = 1;
private static final int DEALER_PLAYER_3 = 2;
private static final int DEALER_MYSELF = 3;
private static final double CARD_START_OPACITY = 0.0;
private static final double CARD_START_SCALE = 0.85;
private static final double COMMUNITY_CARD_HEIGHT_RATIO = 0.22;
private static final double COMMUNITY_CARD_WIDTH_RATIO = 0.11;
private static final double PLAYER_CARD_HEIGHT_RATIO = 0.30;
private static final double PLAYER_CARD_WIDTH_RATIO = 0.15;
private static final double PLAYER_CARDS_Y_OFFSET_FACTOR = 0.10;
private static final int[] CHIP_VALUES = {
100000, 50000, 20000, 10000,
5000, 2000, 1000, 500,
200, 100, 50, 20,
10, 5, 2, 1
};
private static final double CHIP_HEIGHT_RATIO = 0.08;
private static final double CHIP_WIDTH_RATIO = 0.04;
private static final double CHIP_OPACITY_START = 0.0;
private static final double CHIP_SCALE_START = 0.6;
private static final double CHIP_SCALE_END = 1.0;
private static final double CHIP_RANDOM_X_FACTOR = 20.0;
private static final double CHIP_RANDOM_X_CENTER = 0.5;
private static final double CHIP_RANDOM_Y_BASE = -30.0;
private static final double CHIP_RANDOM_Y_VARIATION = 20.0;
private static final double CHIP_TRANSLATE_RESET_X = 0.0;
private static final double CHIP_TRANSLATE_RESET_Y = 0.0;
private static final double CHIP_FADE_TO = 1.0;
private static final double CHIP_SCALE_TO = 1.0;
private static final long CHIP_FADE_DURATION_MS = 200;
private static final long CHIP_SCALE_DURATION_MS = 220;
private static final long CHIP_DROP_DURATION_MS = 250;
private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 35L;
/** Standard constructor. Used by FXML. */
public CasinoGameController() {
// default constructor for FXML
}
@FXML private Label welcomeText;
@FXML private VBox casinoTable;
/**
* Set the GameService for this controller. This method must be called before starting the UI to
* ensure that the controller has access to the game logic and can update the interface
* accordingly.
*
* @param gameService The GameService instance that provides access to the game state and logic.
*/
public void setGameService(GameService gameService) {
this.gameService = gameService;
}
// TODO: Test logic: will be replaced by real game interactions,
// once the game engine is finished
/** Set the PlayerId of the current player. */
@FXML
public void initialize() {
LOGGER.info("INIT UI");
if (communityCardsBox == null) {
LOGGER.warning("communityCardsBox is NULL");
return;
}
try {
var url = getClass().getResource(DEALER_IMAGE_PATH);
if (url != null) {
dealerImage = new Image(url.toExternalForm(), true);
myDealerIcon.setImage(dealerImage);
}
} catch (Exception e) {
LOGGER.warning("Dealer icon load failed");
}
myDealerIcon.setVisible(false);
javafx.stage.Screen screen = javafx.stage.Screen.getPrimary();
double screenHeight = screen.getBounds().getHeight();
casinoTableInnerBox.setTranslateY(-screenHeight * TABLE_OFFSET_Y_FACTOR);
playerCardsBox.setTranslateY(screenHeight * PLAYER_CARDS_OFFSET_Y_FACTOR);
// uiTest();
// empty display only (optional)
renderCommunityCards(List.of());
renderPlayerCards(List.of());
}
// Test method to demonstrate the UI functionality with sample data.
// @FXML
// public void uiTest() {
// if (communityCardsBox == null) {
// LOGGER.info("communityCardsBox is NULL");
// return;
// }
//
// try {
// renderCommunityCards(
// List.of(
// new Card("ace", "hearts"),
// new Card("king", "spades"),
// new Card("10", "diamonds")));
//
// renderPlayerCards(List.of(new Card("10", "spades"), new Card("king", "spades")));
// } catch (Exception e) {
// e.printStackTrace();
// }
//
// Player mathis = new Player(PlayerId.of("Mathis"), 20000);
// mathis.setState(PlayerState.ACTIVE);
//
// Player jona = new Player(PlayerId.of("Jona"), 20000);
// jona.setState(PlayerState.FOLDED);
//
// Player lars = new Player(PlayerId.of("Lars"), 20000);
// lars.setState(PlayerState.ACTIVE);
//
// player1Controller.setPlayer(mathis);
// player2Controller.setPlayer(jona);
// player3Controller.setPlayer(lars);
//
// LOGGER.info(
// "Player 1: "
// + mathis.getName()
// + " | $"
// + mathis.getChips()
// + " | "
// + mathis.getState());
// LOGGER.info(
// "Player 2: "
// + jona.getName()
// + " | $"
// + jona.getChips()
// + " | "
// + jona.getState());
// LOGGER.info(
// "Player 3: "
// + lars.getName()
// + " | $"
// + lars.getChips()
// + " | "
// + lars.getState());
//
// if (playerStatusController != null) {
// playerStatusController.setPlayer(mathis);
// } else {
// LOGGER.warning("PlayerStatusController is NULL");
// }
//
// javafx.stage.Screen screen = javafx.stage.Screen.getPrimary();
// double screenHeight = screen.getBounds().getHeight();
//
// casinoTableInnerBox.setTranslateY(-screenHeight * 0.08);
// playerCardsBox.setTranslateY(screenHeight * 0.35);
//
// renderPot(500);
//
// lars.removeChips(500);
// LOGGER.info("Lars: $" + lars.getChips());
//
// player3Controller.refresh();
//
// mathis.fall();
// player1Controller.refresh();
//
// GameState s = new GameState();
// s.dealer = 3;
// highlightDealer(s);
//
// s.phase = "FLOP";
//
// updateGameInfo(s);
//
// s.winnerIndex = 1;
//
// updateGameInfo(s);
// }
/** Start the UI update loop. */
public void start() {
if (gameService == null) {
throw new IllegalStateException("GameService not set!");
}
startLoop();
}
/**
* Temporary test method that performs a placeholder action when the table is clicked.
*
* <p>In the final implementation, this will be replaced by the game logic.
* Start a loop that periodically updates the UI by fetching the latest game state from the
* GameService.
*/
@FXML
public void onTableClick() {
welcomeText.setText("Einsatz akzeptiert!");
private void startLoop() {
javafx.animation.Timeline t =
new javafx.animation.Timeline(
new javafx.animation.KeyFrame(
javafx.util.Duration.seconds(UI_UPDATE_INTERVAL_SECONDS),
e -> updateUI()));
t.setCycleCount(javafx.animation.Animation.INDEFINITE);
t.play();
}
/**
* Update the UI by fetching the latest game state from the GameService and updating all
* relevant components.
*/
private void updateUI() {
try {
GameState s = gameService.refresh();
if (s == null) {
return;
}
renderCommunityCards(s.communityCards);
renderPot(s.pot);
updatePlayers(s.players);
updatePlayerCards(s);
updateGameInfo(s);
highlightDealer(s);
updateTaskbar(s);
} catch (Exception e) {
LOGGER.severe("Error: UI Update failed: " + e.getMessage());
}
}
/**
* Update the player's hole cards based on the active player in the game state.
*
* @param s The current game state.
*/
private void updatePlayerCards(GameState s) {
int active = s.activePlayer;
if (active >= 0 && active < s.players.size()) {
Player p = s.players.get(active);
renderPlayerCards(p.getCards());
} else {
renderPlayerCards(List.of());
}
}
/**
* Update the game information display, such as the current phase and the winner if the hand has
* ended.
*
* @param s The current game state.
*/
private void updateGameInfo(GameState s) {
String text = "Phase: " + s.phase;
if (s.winnerIndex >= 0 && s.winnerIndex < s.players.size()) {
Player winner = s.players.get(s.winnerIndex);
text = "Winner: " + winner.getName();
}
tableText.setText(text);
}
/**
* Update the taskbar with the current game state and the player's ID.
*
* @param s The current game state.
*/
private void updateTaskbar(GameState s) {
taskbarController.update(s, myPlayerId);
}
/**
* Update the player status components with the latest player information from the game state.
*
* @param p The list of players in the current game state.
*/
private void updatePlayers(List<Player> p) {
if (p == null) {
return;
}
if (p.size() > PLAYER_INDEX_0) {
player1Controller.setPlayer(p.get(PLAYER_INDEX_0));
}
if (p.size() > PLAYER_INDEX_1) {
player2Controller.setPlayer(p.get(PLAYER_INDEX_1));
}
if (p.size() > PLAYER_INDEX_2) {
player3Controller.setPlayer(p.get(PLAYER_INDEX_2));
}
player1Controller.refresh();
player2Controller.refresh();
player3Controller.refresh();
}
/**
* Highlight the dealer in the UI based on the dealer index in the game state.
*
* @param s The current game state containing the dealer index.
*/
private void highlightDealer(GameState s) {
player1Controller.setDealer(false);
player2Controller.setDealer(false);
player3Controller.setDealer(false);
myDealerIcon.setVisible(false);
int dealer = s.dealer;
if (dealer == DEALER_PLAYER_1) {
player1Controller.setDealer(true);
}
if (dealer == DEALER_PLAYER_2) {
player2Controller.setDealer(true);
}
if (dealer == DEALER_PLAYER_3) {
player3Controller.setDealer(true);
}
if (dealer == DEALER_MYSELF) {
myDealerIcon.setVisible(true);
}
}
/**
* Render the community cards on the table based on the list of cards provided.
*
* @param cards The list of community cards to display.
*/
private void renderCommunityCards(List<Card> cards) {
communityCardsBox.getChildren().clear();
if (cards == null) {
cards = List.of();
}
for (int i = 0; i < TOTAL_SLOTS; i++) {
ImageView view = new ImageView();
styleCommunityCard(view);
Image image;
if (i < cards.size() && cards.get(i) != null) {
image = loadImageSafe(mapCardToImage(cards.get(i)));
} else {
image = loadImageSafe(BACKSIDE);
}
view.setImage(image);
view.setOnMouseEntered(e -> animateCardHover(view, true));
view.setOnMouseExited(e -> animateCardHover(view, false));
view.setOpacity(CARD_START_OPACITY);
view.setScaleX(CARD_START_SCALE);
view.setScaleY(CARD_START_SCALE);
communityCardsBox.getChildren().add(view);
animateCardAppear(view, i);
}
}
/**
* Render the player's hole cards based on the list of cards provided.
*
* @param cards The list of hole cards to display for the player.
*/
private void renderPlayerCards(List<Card> cards) {
playerCardsBox.getChildren().clear();
playerCardsBox.getChildren().add(myDealerIcon);
if (cards == null) {
cards = List.of();
}
for (int i = 0; i < PLAYER_SLOTS; i++) {
ImageView view = new ImageView();
stylePlayerCard(view);
Image image;
if (i < cards.size() && cards.get(i) != null) {
image = loadImageSafe(mapCardToImage(cards.get(i)));
} else {
image = loadImageSafe(BACKSIDE);
}
view.setImage(image);
view.setOnMouseEntered(e -> animateCardHover(view, true));
view.setOnMouseExited(e -> animateCardHover(view, false));
view.setOpacity(CARD_START_OPACITY);
view.setScaleX(CARD_START_SCALE);
view.setScaleY(CARD_START_SCALE);
playerCardsBox.getChildren().add(view);
animateCardAppear(view, i);
}
}
/**
* Load an image from the given path safely, falling back to a default backside image if the
* specified image cannot be found.
*
* @param path The path to the image resource to load.
* @return The loaded Image object.
*/
private Image loadImageSafe(String path) {
var stream = getClass().getResourceAsStream(path);
if (stream == null) {
LOGGER.warning("IMAGE NOT FOUND: " + path);
stream = getClass().getResourceAsStream(BACKSIDE);
}
return new Image(stream);
}
/**
* Apply styling to the given ImageView for community cards, including CSS classes and size
* bindings.
*
* @param view The ImageView to style as a community card.
*/
private void styleCommunityCard(ImageView view) {
view.getStyleClass().add("community-card");
view.setPreserveRatio(true);
view.setSmooth(true);
javafx.scene.Scene scene = communityCardsBox.getScene();
if (scene != null) {
bindCommunityCardSize(view, scene);
} else {
communityCardsBox
.sceneProperty()
.addListener(
(obs, oldScene, newScene) -> {
if (newScene != null) {
bindCommunityCardSize(view, newScene);
}
});
}
}
/**
* Apply styling to the given ImageView for player hole cards, including CSS classes and size
* bindings.
*
* @param view The ImageView to style as a player hole card.
*/
private void stylePlayerCard(ImageView view) {
view.getStyleClass().add("player-card");
view.setPreserveRatio(true);
view.setSmooth(true);
javafx.scene.Scene scene = playerCardsBox.getScene();
if (scene != null) {
bindPlayerCardSize(view, scene);
} else {
playerCardsBox
.sceneProperty()
.addListener(
(obs, oldScene, newScene) -> {
if (newScene != null) {
bindPlayerCardSize(view, newScene);
}
});
}
}
/**
* Bind the size of the given ImageView to the scene dimensions for community cards.
*
* @param view The ImageView representing a community card whose size should be bound to the
* scene dimensions.
* @param scene The JavaFX Scene to which the ImageView belongs, used for binding the size
* properties.
*/
private void bindCommunityCardSize(ImageView view, javafx.scene.Scene scene) {
view.fitHeightProperty().bind(scene.heightProperty().multiply(COMMUNITY_CARD_HEIGHT_RATIO));
view.fitWidthProperty().bind(scene.widthProperty().multiply(COMMUNITY_CARD_WIDTH_RATIO));
}
/**
* Bind the size of the given ImageView to the scene dimensions for player hole cards.
*
* @param view The ImageView representing a player hole card whose size should be bound to the
* scene dimensions.
* @param scene The JavaFX Scene to which the ImageView belongs, used for binding the size
* properties.
*/
private void bindPlayerCardSize(ImageView view, javafx.scene.Scene scene) {
view.fitHeightProperty().bind(scene.heightProperty().multiply(PLAYER_CARD_HEIGHT_RATIO));
view.fitWidthProperty().bind(scene.widthProperty().multiply(PLAYER_CARD_WIDTH_RATIO));
}
/**
* Animate the appearance of a card by applying a sequence of transitions, including movement,
* fading, scaling, and settling.
*
* @param view The ImageView representing the card to animate.
* @param index The index of the card in the display order.
*/
private void animateCardAppear(ImageView view, int index) {
javafx.animation.TranslateTransition move =
new javafx.animation.TranslateTransition(
javafx.util.Duration.millis(ANIMATION_DURATION_MS), view);
move.setFromX(MOVE_FROM_X);
move.setToX(MOVE_TO_X);
move.setFromY(MOVE_FROM_Y);
move.setToY(MOVE_TO_Y);
move.setInterpolator(javafx.animation.Interpolator.EASE_OUT);
javafx.animation.FadeTransition fade =
new javafx.animation.FadeTransition(
javafx.util.Duration.millis(ANIMATION_DURATION_MS), view);
fade.setFromValue(FADE_FROM);
fade.setToValue(FADE_TO);
javafx.animation.ScaleTransition scale =
new javafx.animation.ScaleTransition(
javafx.util.Duration.millis(ANIMATION_DURATION_MS), view);
scale.setFromX(SCALE_FROM);
scale.setFromY(SCALE_FROM);
scale.setToX(SCALE_TO);
scale.setToY(SCALE_TO);
scale.setInterpolator(javafx.animation.Interpolator.EASE_OUT);
javafx.animation.TranslateTransition settle =
new javafx.animation.TranslateTransition(
javafx.util.Duration.millis(SETTLE_DURATION_MS), view);
settle.setFromY(SETTLE_FROM_Y);
settle.setToY(SETTLE_TO_Y);
settle.setAutoReverse(SETTLE_AUTO_REVERSE);
settle.setCycleCount(SETTLE_CYCLE_COUNT);
javafx.animation.SequentialTransition st =
new javafx.animation.SequentialTransition(
new javafx.animation.ParallelTransition(move, fade, scale), settle);
st.setDelay(javafx.util.Duration.millis(index * STAGGER_DELAY_MS));
st.play();
}
/**
* Animate a card hover effect by scaling and lifting the card when hovered and resetting it
* when not hovered.
*
* @param view The ImageView representing the card to animate on hover.
* @param hover A boolean indicating whether the card is being hovered (true) or not (false).
*/
private void animateCardHover(ImageView view, boolean hover) {
double scale = hover ? HOVER_SCALE_ON : HOVER_SCALE_OFF;
double lift = hover ? HOVER_LIFT_ON : HOVER_LIFT_OFF;
javafx.animation.ScaleTransition st =
new javafx.animation.ScaleTransition(
javafx.util.Duration.millis(HOVER_DURATION_MS), view);
st.setToX(scale);
st.setToY(scale);
javafx.animation.TranslateTransition tt =
new javafx.animation.TranslateTransition(
javafx.util.Duration.millis(HOVER_DURATION_MS), view);
tt.setToY(lift);
st.play();
tt.play();
}
/**
* Position the player's hole cards box on the screen based on a predefined offset factor
* relative to the screen height.
*/
private void positionPlayerCards() {
javafx.stage.Screen screen = javafx.stage.Screen.getPrimary();
double screenHeight = screen.getBounds().getHeight();
playerCardsBox.setTranslateY(screenHeight * PLAYER_CARDS_Y_OFFSET_FACTOR);
}
/**
* Map a Card object to the corresponding image path based on its suit and value.
*
* @param card The Card object to be mapped to an image path.
* @return The string path to the image representing the given card.
*/
private String mapCardToImage(Card card) {
if (card == null) {
return BACKSIDE;
}
String suit =
switch (card.getSuit().toLowerCase()) {
case "hearts" -> "heart";
case "diamonds" -> "diamond";
case "clubs", "cross" -> "cross";
case "spades", "pik" -> "pik";
default -> card.getSuit().toLowerCase();
};
String value =
switch (card.getValue().toLowerCase()) {
case "a", "ace" -> "ace";
case "k", "king" -> "king";
case "q", "queen" -> "queen";
case "j", "jack" -> "jack";
case "10", "t" -> "10";
default -> card.getValue().toLowerCase();
};
String path = "/images/card-" + suit + "-" + value + "-3.png";
// LOGGER.info("CARD PATH: " + path);
return path;
}
/**
* Render the pot by calculating the required chips based on the pot amount and displaying them
* with animations.
*
* @param pot The total amount in the pot that needs to be represented with chips on the UI.
*/
private void renderPot(int pot) {
potBox.getChildren().clear();
int remaining = pot;
int index = 0;
for (int chipValue : CHIP_VALUES) {
while (remaining >= chipValue) {
ImageView chip =
new ImageView(loadImageSafe("/images/chip-" + chipValue + "-5.png"));
if (chip.getImage() == null) {
LOGGER.warning("Missing chip image: " + chipValue);
break;
}
styleChip(chip);
potBox.getChildren().add(chip);
animateChipAppear(chip, index);
remaining -= chipValue;
index++;
}
}
// LOGGER.info("POT RENDERED: " + pot + " -> remaining: " + remaining);
}
/**
* Apply styling to the given ImageView for chips, including CSS classes and size bindings.
*
* @param view The ImageView to style as a chip in the pot display.
*/
private void styleChip(ImageView view) {
view.getStyleClass().add("chips-icon");
view.setPreserveRatio(true);
view.setSmooth(true);
javafx.scene.Scene scene = potBox.getScene();
if (scene != null) {
bindChipSize(view, scene);
} else {
potBox.sceneProperty()
.addListener(
(obs, oldScene, newScene) -> {
if (newScene != null) {
bindChipSize(view, newScene);
}
});
}
}
/**
* Bind the size of the given ImageView to the scene dimensions for chips in the pot display.
*
* @param view The ImageView representing a chip whose size should be bound to the scene
* dimensions.
* @param scene The JavaFX Scene to which the ImageView belongs, used for binding the size
* properties.
*/
private void bindChipSize(ImageView view, javafx.scene.Scene scene) {
view.fitHeightProperty()
.bind(
scene.heightProperty().multiply(CHIP_HEIGHT_RATIO) // kleiner als Karten
);
view.fitWidthProperty().bind(scene.widthProperty().multiply(CHIP_WIDTH_RATIO));
}
/**
* Animate the appearance of a chip by applying a sequence of transitions, including random
* initial positioning, fading, scaling, and dropping into place.
*
* @param view The ImageView representing the chip to animate.
* @param index The index of the chip in the display order, used to stagger the animation timing
* for multiple chips.
*/
private void animateChipAppear(ImageView view, int index) {
view.setOpacity(CHIP_OPACITY_START);
view.setScaleX(CHIP_SCALE_START);
view.setScaleY(CHIP_SCALE_START);
double randomX = (Math.random() - CHIP_RANDOM_X_CENTER) * CHIP_RANDOM_X_FACTOR;
double randomY = CHIP_RANDOM_Y_BASE - Math.random() * CHIP_RANDOM_Y_VARIATION;
view.setTranslateX(randomX);
view.setTranslateY(randomY);
javafx.animation.FadeTransition fade =
new javafx.animation.FadeTransition(
javafx.util.Duration.millis(CHIP_FADE_DURATION_MS), view);
fade.setToValue(CHIP_FADE_TO);
javafx.animation.ScaleTransition scale =
new javafx.animation.ScaleTransition(
javafx.util.Duration.millis(CHIP_SCALE_DURATION_MS), view);
scale.setToX(CHIP_SCALE_TO);
scale.setToY(CHIP_SCALE_TO);
javafx.animation.TranslateTransition drop =
new javafx.animation.TranslateTransition(
javafx.util.Duration.millis(CHIP_DROP_DURATION_MS), view);
drop.setToX(CHIP_TRANSLATE_RESET_X);
drop.setToY(CHIP_TRANSLATE_RESET_Y);
javafx.animation.SequentialTransition st =
new javafx.animation.SequentialTransition(fade, scale, drop);
st.setDelay(javafx.util.Duration.millis(index * CHIP_STAGGER_DELAY_MULTIPLIER));
st.play();
}
}
@@ -7,26 +7,30 @@ import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
/**
* Main class for the Casono Game UI.
*
* <p>Starts the JavaFX application, loads the graphical user interface from the FXML file, and
* initializes the main stage for the game.
*
* <p>Tasks: - Loads the FXML interface "/ui-structure/Casinogameui.fxml". - Loads the application
* icon from "/images/logoinverted.png". - Starts the application in full-screen mode.
*/
public class CasinoGameUI extends Application {
// Static field for ClientService (workaround for JavaFX Application launch)
private static ClientService staticClientService;
private static ClientService clientService;
public static void setClientService(ClientService clientService) {
staticClientService = clientService;
}
private static final int DEFAULT_WIDTH = 1200;
private static final int DEFAULT_HEIGHT = 800;
public static ClientService getClientService() {
return staticClientService;
}
/** Default no-arg constructor. */
/** default constructor */
public CasinoGameUI() {
// default no-arg constructor
}
private static final int DEFAULT_WIDTH = 1200;
private static final int DEFAULT_HEIGHT = 800;
public static void setClientService(ClientService clientService) {
CasinoGameUI.clientService = clientService;
}
/**
* Starts the main stage of the application.
@@ -39,7 +43,7 @@ public class CasinoGameUI extends Application {
FXMLLoader fxmlLoader =
new FXMLLoader(CasinoGameUI.class.getResource("/ui-structure/Casinogameui.fxml"));
Scene scene = new Scene(fxmlLoader.load(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
stage.setTitle("Casono (GAME)");
stage.setTitle("Casono");
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
stage.getIcons().add(new javafx.scene.image.Image(iconPath));
@@ -49,7 +53,7 @@ public class CasinoGameUI extends Application {
}
/**
* Entry point of the application.
* Starting point of the application.
*
* @param args Command line arguments.
*/
@@ -0,0 +1,110 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import java.util.logging.Logger;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
/**
* Controller for displaying a player's status in the poker game, including their name, chip count
* and whether they are the dealer.
*/
public class PlayerStatusController {
private static final Logger LOGGER = Logger.getLogger(PlayerStatusController.class.getName());
@FXML private Label playerName;
@FXML private Label playerMoney;
@FXML private ImageView dealerIcon;
@FXML private Pane parent;
private Image dealerImage;
private Player player;
private static final String DEALER_IMAGE_PATH = "/images/chip-dealer-blue-3.png";
private static final double DEALER_ICON_X_FACTOR = 0.8;
/** Initialize the controller, load dealer image, and set up bindings. */
@FXML
public void initialize() {
try {
var url = getClass().getResource(DEALER_IMAGE_PATH);
if (url != null) {
dealerImage = new Image(url.toExternalForm(), true);
dealerIcon.setImage(dealerImage);
} else {
LOGGER.warning("Dealer image not found in resources!");
}
} catch (Exception e) {
LOGGER.severe("Error: loading dealer image:");
e.printStackTrace();
}
dealerIcon.layoutXProperty().bind(parent.widthProperty().multiply(DEALER_ICON_X_FACTOR));
dealerIcon.setVisible(false);
}
/**
* Bind player to UI
*
* @param player The player whose status is to be displayed.
*/
public void setPlayer(Player player) {
this.player = player;
refresh();
}
/** Refresh UI safely */
public void refresh() {
if (player == null) {
return;
}
// NAME (safe)
String name = player.getName();
playerName.setText(name != null ? name : "-");
// MONEY
playerMoney.setText(player.getChips() + " $");
// DEALER
boolean isDealer = player.getState() == PlayerState.DEALER;
dealerIcon.setVisible(isDealer);
if (isDealer && dealerImage != null) {
dealerIcon.setImage(dealerImage);
}
}
/**
* External quick update
*
* @param name The player's name to display.
* @param chips The player's chip count to display.
*/
public void updatePlayer(String name, int chips) {
playerName.setText(name != null ? name : "-");
playerMoney.setText(chips + " $");
}
/**
* Force dealer state
*
* @param isDealer Whether the player is the dealer or not.
*/
public void setDealer(boolean isDealer) {
dealerIcon.setVisible(isDealer);
if (isDealer && dealerImage != null) {
dealerIcon.setImage(dealerImage);
}
}
}
@@ -1,7 +1,14 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
@@ -18,22 +25,225 @@ import org.apache.logging.log4j.Logger;
*/
public class TaskbarController {
private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class);
@FXML private HBox taskbar;
@FXML private TextField taskbarInput;
@FXML private Button betButton;
@FXML private Button callButton;
@FXML private Button foldButton;
@FXML private Button raiseButton;
@FXML private Label moneyLabel;
private GameService gameService;
private PlayerId myPlayerId;
private double xOffset = 0;
private double yOffset = 0;
private static final double TASKBAR_SCALE = 0.95;
private static final int MIN_CREDITS = 5;
private static final int MAX_CREDITS = 100000;
private static final int CREDIT_STEP = 5;
private static final String STYLE_YELLOW_BUTTON = "yellow-button";
private static final String STYLE_GRAY_BUTTON = "gray-button";
private static final String STYLE_RED_BUTTON = "red-button";
private static final String STYLE_YELLOW_INPUT = "yellow-input-field";
private static final String STYLE_GRAY_INPUT = "gray-input-field";
private static final String STYLE_RED_INPUT = "red-input-field";
private static final double SCALE_NORMAL = 1.0;
/** Standard constructor. Used by FXML. */
public TaskbarController() {
// default constructor for FXML
}
private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class);
/**
* Updates the state of the basic action buttons (Bet, Call, Fold, Raise) and the input field
*
* @param isMyTurn Indicates whether it is currently the player's turn.
* @param isOut Indicates whether the player is currently out of the game (folded or all-in).
*/
private void updateBasicButtons(boolean isMyTurn, boolean isOut) {
@FXML private HBox taskbar;
@FXML private TextField taskbarInput;
if (isOut) {
setRedButton(betButton);
setRedButton(callButton);
setRedButton(foldButton);
setRedButton(raiseButton);
setRedInputField(taskbarInput);
return;
}
private double xOffset = 0;
private double yOffset = 0;
private static final double TASKBAR_SCALE = 0.9;
private static final int MIN_CREDITS = 5;
private static final int MAX_CREDITS = 100000;
private static final int CREDIT_STEP = 5;
if (isMyTurn) {
activateButton(betButton);
activateButton(callButton);
activateButton(foldButton);
activateButton(raiseButton);
activateInputField(taskbarInput);
} else {
deactivateButton(betButton);
deactivateButton(callButton);
deactivateButton(foldButton);
deactivateButton(raiseButton);
deactivateInputField(taskbarInput);
}
}
/**
* Sets the given button to a disabled state with red styling, indicating that the player is out
*
* @param b The button to be styled as red and disabled
*/
private void setRedButton(Button b) {
b.setDisable(true);
b.getStyleClass().remove(STYLE_YELLOW_BUTTON);
b.getStyleClass().remove(STYLE_GRAY_BUTTON);
if (!b.getStyleClass().contains(STYLE_RED_BUTTON)) {
b.getStyleClass().add(STYLE_RED_BUTTON);
}
}
/**
* Sets the given input field to a disabled state with red styling, indicating that the player
* is out
*
* @param t The text field to be styled as red and disabled
*/
private void setRedInputField(TextField t) {
t.setDisable(true);
t.getStyleClass().remove(STYLE_YELLOW_INPUT);
t.getStyleClass().remove(STYLE_GRAY_INPUT);
if (!t.getStyleClass().contains(STYLE_RED_INPUT)) {
t.getStyleClass().add(STYLE_RED_INPUT);
}
}
/**
* Activates the given button by enabling it and applying yellow styling, indicating that it is
* the player's turn
*
* @param b The button to be activated and styled for the player's turn
*/
private void activateButton(Button b) {
b.setDisable(false);
b.getStyleClass().remove(STYLE_GRAY_BUTTON);
if (!b.getStyleClass().contains(STYLE_YELLOW_BUTTON)) {
b.getStyleClass().add(STYLE_YELLOW_BUTTON);
}
}
/**
* Activates the given input field by enabling it and applying yellow styling, indicating that
* it is the player's turn
*
* @param t The text field to be activated and styled for the player's turn
*/
private void activateInputField(TextField t) {
t.setDisable(false);
t.getStyleClass().remove(STYLE_GRAY_INPUT);
if (!t.getStyleClass().contains(STYLE_YELLOW_INPUT)) {
t.getStyleClass().add(STYLE_YELLOW_INPUT);
}
}
/**
* Deactivates the given button by disabling it and applying gray styling, indicating that it is
* not the player's turn
*
* @param b The button to be deactivated and styled for non-active state
*/
private void deactivateButton(Button b) {
b.setDisable(true);
b.getStyleClass().remove(STYLE_YELLOW_BUTTON);
b.getStyleClass().remove(STYLE_RED_BUTTON);
if (!b.getStyleClass().contains(STYLE_GRAY_BUTTON)) {
b.getStyleClass().add(STYLE_GRAY_BUTTON);
}
}
/**
* Deactivates the given input field by disabling it and applying gray styling, indicating that
* it is not the player's turn
*
* @param t The text field to be deactivated and styled for non-active state
*/
private void deactivateInputField(TextField t) {
t.setDisable(true);
t.getStyleClass().remove(STYLE_YELLOW_INPUT);
t.getStyleClass().remove(STYLE_RED_INPUT);
if (!t.getStyleClass().contains(STYLE_GRAY_INPUT)) {
t.getStyleClass().add(STYLE_GRAY_INPUT);
}
}
/**
* Updates the displayed amount of money the player has. The amount is shown in the format "X$".
*
* @param amount The amount of money to display for the player.
*/
public void setMoney(int amount) {
moneyLabel.setText(amount + "$");
}
public void setGameService(GameService gameService, PlayerId myPlayerId) {
this.gameService = gameService;
this.myPlayerId = myPlayerId;
}
/**
* Initializes the taskbar controller. This method is called automatically after the FXML
* components have been loaded.
*/
@FXML
public void initialize() {
updateBasicButtons(false, false);
}
/**
* Updates the taskbar based on the current game state and the player's status. It checks if
* it's the player's turn and whether they are out of the game (folded or all-in) to adjust the
* button states and displayed money accordingly.
*
* @param state
* @param myPlayerId
*/
public void update(GameState state, PlayerId myPlayerId) {
if (state == null || state.players == null || myPlayerId == null) {
LOGGER.warn("Cannot update taskbar: invalid input");
return;
}
Player me =
state.players.stream()
.filter(p -> myPlayerId.equals(p.getId()))
.findFirst()
.orElse(null);
if (me == null) {
LOGGER.error("Player not found in GameState!");
return;
}
int myIndex = state.players.indexOf(me);
boolean isMyTurn = state.activePlayer == myIndex;
boolean isOut = me.getState() == PlayerState.FOLDED;
updateBasicButtons(isMyTurn, isOut);
setMoney(me.getChips());
}
/**
* Called when the taskbar is clicked with the mouse. Saves the relative position for later,
@@ -72,8 +282,8 @@ public class TaskbarController {
*/
@FXML
private void onTaskbarReleased(MouseEvent event) {
taskbar.setScaleX(1.0);
taskbar.setScaleY(1.0);
taskbar.setScaleX(SCALE_NORMAL);
taskbar.setScaleY(SCALE_NORMAL);
}
/**
@@ -97,6 +307,76 @@ public class TaskbarController {
processBet();
}
/** Called when the Call button is clicked. */
@FXML
private void onInputPlayerCall() {
if (gameService == null) {
LOGGER.error("GameService not initialized");
return;
}
gameService.call();
LOGGER.info("Player CALL");
refreshGame();
}
/** Called when the Fold button is clicked. */
@FXML
private void onInputPlayerFold() {
if (gameService == null) {
LOGGER.error("GameService not initialized");
return;
}
gameService.fold();
LOGGER.info("Player FOLD");
refreshGame();
}
/** Called when the Raise button is clicked. */
@FXML
private void onInputPlayerRaise() {
String input = taskbarInput.getText();
try {
int amount = Integer.parseInt(input.trim());
gameService.raise(amount);
LOGGER.info("Player RAISE {}", amount);
taskbarInput.clear();
refreshGame();
} catch (NumberFormatException e) {
LOGGER.error("Invalid raise amount");
}
}
/**
* Refreshes the game state by fetching the latest state from the GameService and updating the
* taskbar accordingly.
*/
private void refreshGame() {
try {
GameState newState = gameService.refresh();
update(newState, myPlayerId);
} catch (Exception e) {
LOGGER.error("Failed to refresh game state: {}", e.getMessage());
}
}
/**
* Called when the Exit button is clicked. Closes the current game stage and opens the lobby UI.
*/
@FXML
private void onExitButtonClick() {
javafx.application.Platform.runLater(
@@ -109,7 +389,7 @@ public class TaskbarController {
try {
new Casinomainui().start(new javafx.stage.Stage());
} catch (Exception e) {
LOGGER.error("Fehler beim Starten der Lobby-UI: {}", e.getMessage());
LOGGER.error("Error: starting the lobby UI: {}", e.getMessage());
}
});
}
@@ -120,19 +400,34 @@ public class TaskbarController {
* displayed on the console.
*/
private void processBet() {
if (gameService == null) {
LOGGER.error("Error: GameService not initialized");
return;
}
String input = taskbarInput.getText();
try {
int credits = Integer.parseInt(input.trim());
if (credits >= MIN_CREDITS && credits <= MAX_CREDITS && credits % CREDIT_STEP == 0) {
// TODO: Credits must be sent to the GameEngine
LOGGER.info("Bet set: {} Casono Credits", credits);
gameService.bet(credits);
LOGGER.info("Bet placed: {}", credits);
taskbarInput.clear();
refreshGame();
} else {
LOGGER.error("Error: Only increments of 5 (5, 10, ... 100,000) are allowed!");
LOGGER.error("Error: Bet must be between 5 and 100000 and multiple of 5");
}
} catch (NumberFormatException e) {
LOGGER.error("Error: Please enter only one number!");
LOGGER.error("Error: Invalid number entered");
}
}
@@ -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()) {
@@ -24,6 +24,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager;
@@ -85,7 +86,8 @@ public class ServerApp {
SESSION_DISCONNECT_JOB_PERIOD,
TimeUnit.SECONDS);
registerCommands(dispatcher, router, responseDispatcher, userRegistry);
LobbyManager lobbyManager = new LobbyManager();
registerCommands(dispatcher, router, responseDispatcher, userRegistry, lobbyManager);
NetworkManager networkManager = new NetworkManager(port, sessionManager, router);
networkManager.start();
@@ -102,7 +104,8 @@ public class ServerApp {
CommandParserDispatcher parserDispatcher,
CommandRouter commandRouter,
ResponseDispatcher responseDispatcher,
UserRegistry userRegistry) {
UserRegistry userRegistry,
LobbyManager lobbyManager) {
parserDispatcher.register("PING", new PingParser());
commandRouter.register(PingRequest.class, new PingHandler(responseDispatcher));
@@ -136,5 +139,118 @@ public class ServerApp {
parserDispatcher.register("LIST_USERS", new ListUsersParser());
commandRouter.register(
ListUsersRequest.class, new ListUsersHandler(responseDispatcher, userRegistry));
// GET_LOBBY_LIST registration
parserDispatcher.register(
"GET_LOBBY_LIST",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_list.GetLobbyListRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListHandler(responseDispatcher, lobbyManager));
// GET_GAME_STATE registration
parserDispatcher.register(
"GET_GAME_STATE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game
.get_game_state.GetGameStateRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.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",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusHandler(
responseDispatcher, lobbyManager, userRegistry));
// JOIN_LOBBY registration
parserDispatcher.register(
"JOIN_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyHandler(responseDispatcher, lobbyManager, userRegistry));
}
}
@@ -0,0 +1,33 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.add_player;
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.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 ADD_PLAYER command. */
public class AddPlayerHandler extends CommandHandler<AddPlayerRequest> {
private final UserRegistry userRegistry;
public AddPlayerHandler(UserRegistry userRegistry, ResponseDispatcher responseDispatcher) {
super(responseDispatcher);
this.userRegistry = userRegistry;
}
@Override
public void execute(AddPlayerRequest request) {
Optional<User> created =
userRegistry.registerIfAvailable(
request.getName(), request.getContext().sessionId());
if (created.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "NAME_TAKEN", "Name already taken"));
return;
}
responseDispatcher.dispatch(new OkResponse(request.getContext()));
}
}
@@ -0,0 +1,20 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.add_player;
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 ADD_PLAYER command. */
public class AddPlayerParser implements CommandParser<AddPlayerRequest> {
@Override
public AddPlayerRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
String name = accessor.require("NAME");
int chips = accessor.require("CHIPS", Integer::parseInt);
return new AddPlayerRequest(primitiveRequest.context(), name, chips);
}
}
@@ -0,0 +1,24 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.add_player;
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 data for the ADD_PLAYER command. */
public class AddPlayerRequest extends Request {
private final String name;
private final int chips;
public AddPlayerRequest(RequestContext context, String name, int chips) {
super(context);
this.name = name;
this.chips = chips;
}
public String getName() {
return name;
}
public int getChips() {
return chips;
}
}
@@ -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,74 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.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.domain.user.User;
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.dispatcher.ResponseDispatcher;
/** Handler for GET_GAME_STATE: returns pot, phase, community cards and per-player info. */
public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
private final LobbyManager lobbyManager;
private final UserRegistry userRegistry;
public GetGameStateHandler(
ResponseDispatcher responseDispatcher,
LobbyManager lobbyManager,
UserRegistry userRegistry) {
super(responseDispatcher);
this.lobbyManager = lobbyManager;
this.userRegistry = userRegistry;
addCheck(new UserLoggedInCheck(userRegistry));
}
@Override
public void execute(GetGameStateRequest request) {
Integer gameId = request.getGameId();
String username = request.getUsername();
Lobby lobby = null;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
if (lobby == null) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
return;
}
} else {
if (username == null) {
var maybeUser = userRegistry.getBySessionId(request.getSessionId());
if (maybeUser.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "NOT_LOGGED_IN", "User not logged in"));
return;
}
User u = maybeUser.get();
username = u.getName();
}
lobby = lobbyManager.getLobbyByUsername(username);
if (lobby == null) {
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;
}
responseDispatcher.dispatch(new GetGameStateResponse(request.getContext(), game, username));
}
}
@@ -0,0 +1,16 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state;
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;
public class GetGameStateParser implements CommandParser<GetGameStateRequest> {
@Override
public GetGameStateRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
Integer gameId = accessor.optional("GAME_ID", null, Integer::parseInt);
String username = accessor.optional("USERNAME", null);
return new GetGameStateRequest(primitiveRequest.context(), username, gameId);
}
}
@@ -0,0 +1,31 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state;
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 object for `GET_GAME_STATE`.
*
* <p>Either `USERNAME` or `GAME_ID` may be provided by the client. If both are omitted the handler
* will resolve the username from the session.
*/
public class GetGameStateRequest extends Request {
private final String username;
private final Integer gameId;
public GetGameStateRequest(RequestContext context, String username, Integer gameId) {
super(context);
this.username = username;
this.gameId = gameId;
}
/** Returns the optional username provided in the request. */
public String getUsername() {
return username;
}
/** Returns the optional game id provided in the request. */
public Integer getGameId() {
return gameId;
}
}
@@ -0,0 +1,114 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Card;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Rank;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Suit;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
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.protocol.response.builder.ResponseBodyBuilder;
import java.util.List;
/** Response carrying a snapshot of the current game state using the project's wire format. */
public class GetGameStateResponse extends SuccessResponse {
public GetGameStateResponse(
RequestContext context, GameController game, String requestingUsername) {
super(context, buildBody(game.getState(), requestingUsername));
}
private static ResponseBody buildBody(GameState state, String requestingUsername) {
int globalCurrentBet = computeGlobalCurrentBet(state);
ResponseBodyBuilder builder = ResponseBody.builder();
builder.param("PHASE", state.getPhase() == null ? "UNKNOWN" : state.getPhase().name());
builder.param("POT", state.getPot().getAmount());
builder.param("CURRENT_BET", globalCurrentBet);
builder.param("DEALER", state.getDealerIndex());
builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex());
appendCommunityCards(builder, state);
appendPlayers(builder, state, requestingUsername);
return builder.build();
}
private static int computeGlobalCurrentBet(GameState state) {
int globalCurrentBet = 0;
for (Player p : state.getPlayers()) {
int b = state.getCurrentBet(p.getId());
if (b > globalCurrentBet) {
globalCurrentBet = b;
}
}
return globalCurrentBet;
}
private static void appendCommunityCards(ResponseBodyBuilder builder, GameState state) {
for (Card c : state.getCommunityCards()) {
builder.block(
"CARD",
card -> {
card.param("VALUE", rankToWire(c.getRank()));
card.param("SUIT", suitToWire(c.getSuit()));
});
}
}
private static void appendPlayers(
ResponseBodyBuilder builder, GameState state, String requestingUsername) {
for (Player p : state.getPlayers()) {
PlayerId pid = p.getId();
builder.block(
"PLAYER",
pblock -> {
pblock.param("NAME", pid.value());
pblock.param("CHIPS", p.getChips());
pblock.param("BET", state.getCurrentBet(pid));
pblock.param("STATE", p.isFolded() ? "FOLDED" : "ACTIVE");
if (requestingUsername != null && requestingUsername.equals(pid.value())) {
List<Card> hole = state.getHoleCards(pid);
for (Card hc : hole) {
pblock.block(
"CARD",
cblock -> {
cblock.param("VALUE", rankToWire(hc.getRank()));
cblock.param("SUIT", suitToWire(hc.getSuit()));
});
}
}
});
}
}
private static String rankToWire(Rank r) {
return switch (r) {
case TWO -> "2";
case THREE -> "3";
case FOUR -> "4";
case FIVE -> "5";
case SIX -> "6";
case SEVEN -> "7";
case EIGHT -> "8";
case NINE -> "9";
case TEN -> "10";
case JACK -> "J";
case QUEEN -> "Q";
case KING -> "K";
case ACE -> "A";
};
}
private static String suitToWire(Suit s) {
return switch (s) {
case HEARTS -> "H";
case DIAMONDS -> "D";
case CLUBS -> "C";
case SPADES -> "S";
};
}
}
@@ -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,28 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list;
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.dispatcher.ResponseDispatcher;
import java.util.Collection;
/**
* Handler for the `GET_LOBBY_LIST` command.
*
* <p>Queries the {@link LobbyManager} for all available lobbies and dispatches a {@link
* GetLobbyListResponse} containing basic metadata for each lobby (`ID`, `NAME`, `PLAYER_COUNT`).
*/
public class GetLobbyListHandler extends CommandHandler<GetLobbyListRequest> {
private final LobbyManager lobbyManager;
public GetLobbyListHandler(ResponseDispatcher responseDispatcher, LobbyManager lobbyManager) {
super(responseDispatcher);
this.lobbyManager = lobbyManager;
}
@Override
public void execute(GetLobbyListRequest request) {
Collection<ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby> l =
lobbyManager.getAllLobbies();
responseDispatcher.dispatch(new GetLobbyListResponse(request.getContext(), l));
}
}
@@ -0,0 +1,17 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
/**
* Parser for the `GET_LOBBY_LIST` command.
*
* <p>Parses the incoming primitive request and produces a {@link GetLobbyListRequest}. This command
* takes no parameters.
*/
public class GetLobbyListParser implements CommandParser<GetLobbyListRequest> {
@Override
public GetLobbyListRequest parse(PrimitiveRequest primitiveRequest) {
return new GetLobbyListRequest(primitiveRequest.context());
}
}
@@ -0,0 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list;
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 object for the `GET_LOBBY_LIST` command.
*
* <p>Contains only the request context; this command does not require parameters.
*/
public class GetLobbyListRequest extends Request {
public GetLobbyListRequest(RequestContext context) {
super(context);
}
}
@@ -0,0 +1,37 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.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.ResponseBody;
import java.util.Collection;
/**
* Success response for `GET_LOBBY_LIST`.
*
* <p>Builds a `LOBBIES` collection with repeated `LOBBY` blocks containing `ID`, `NAME` and
* `PLAYER_COUNT`.
*/
public class GetLobbyListResponse extends SuccessResponse {
public GetLobbyListResponse(RequestContext context, Collection<Lobby> lobbies) {
super(
context,
ResponseBody.builder()
.block(
"LOBBIES",
lobbies_block -> {
for (Lobby lobby : lobbies) {
lobbies_block.block(
"LOBBY",
lb -> {
lb.param("ID", lobby.getId().value());
lb.param("NAME", lobby.getName());
lb.param(
"PLAYER_COUNT",
lobby.getPlayerNames().size());
});
}
})
.build());
}
}
@@ -0,0 +1,81 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status;
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.dispatcher.ResponseDispatcher;
import java.util.Optional;
/**
* Handler for the `GET_LOBBY_STATUS` command.
*
* <p>Resolves the target lobby either by the optional `ID` parameter or by `USERNAME`. If both are
* omitted the handler requires the session to be associated with a logged-in user (see the inline
* pre-execution check). On success a {@link GetLobbyStatusResponse} is dispatched, otherwise an
* {@link ErrorResponse} with code `LOBBY_NOT_FOUND` is sent.
*/
public class GetLobbyStatusHandler extends CommandHandler<GetLobbyStatusRequest> {
private final LobbyManager lobbyManager;
private final UserRegistry userRegistry;
public GetLobbyStatusHandler(
ResponseDispatcher responseDispatcher,
LobbyManager lobbyManager,
UserRegistry userRegistry) {
super(responseDispatcher);
this.lobbyManager = lobbyManager;
this.userRegistry = userRegistry;
// Only require logged-in session if the request does not provide an explicit ID
// or USERNAME
addCheck(
request -> {
if (!(request instanceof GetLobbyStatusRequest)) {
return Optional.empty();
}
GetLobbyStatusRequest r = (GetLobbyStatusRequest) request;
if (r.getId() != null || r.getUsername() != null) {
return Optional.empty();
}
var maybeUser = userRegistry.getBySessionId(r.getSessionId());
if (maybeUser.isPresent()) {
return Optional.empty();
}
return Optional.of(
new ErrorResponse(
r.getContext(),
"USER_NOT_LOGGED_IN",
"Request requires an associated logged-in user"));
});
}
@Override
public void execute(GetLobbyStatusRequest request) {
// Resolve username from request or session when no explicit ID/USERNAME
// provided
String targetUsername = request.getUsername();
if (targetUsername == null) {
var maybeUser = userRegistry.getBySessionId(request.getSessionId());
if (maybeUser.isPresent()) {
targetUsername = maybeUser.get().getName();
}
}
var lobby =
(request.getId() != null)
? lobbyManager.getLobby(LobbyId.of(request.getId()))
: (targetUsername != null
? lobbyManager.getLobbyByUsername(targetUsername)
: null);
if (lobby == null) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
return;
}
responseDispatcher.dispatch(new GetLobbyStatusResponse(request.getContext(), lobby));
}
}
@@ -0,0 +1,36 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status;
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;
public class GetLobbyStatusParser implements CommandParser<GetLobbyStatusRequest> {
/**
* Parse the incoming primitive request into a {@link GetLobbyStatusRequest}.
*
* <p>Supported optional parameters:
*
* <ul>
* <li>`ID` (int) — numeric lobby id to query
* <li>`USERNAME` (String) — username to lookup the lobby for
* </ul>
*
* If both are omitted the handler may require a logged-in session.
*
* @param primitiveRequest raw request containing parameters and context
* @return parsed {@link GetLobbyStatusRequest}
*/
@Override
public GetLobbyStatusRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
Integer id = null;
try {
id = accessor.optional("ID", null, Integer::parseInt);
} catch (Exception e) {
// parse error handled elsewhere
}
String username = accessor.optional("USERNAME", null);
return new GetLobbyStatusRequest(primitiveRequest.context(), id, username);
}
}
@@ -0,0 +1,32 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status;
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 GetLobbyStatusRequest extends Request {
private final Integer id;
private final String username;
/**
* Create a new GetLobbyStatusRequest.
*
* @param context request context (session id, source, etc.)
* @param id optional numeric lobby id to query, or null
* @param username optional username to query the lobby for, or null
*/
public GetLobbyStatusRequest(RequestContext context, Integer id, String username) {
super(context);
this.id = id;
this.username = username;
}
/** Returns the optional lobby id. */
public Integer getId() {
return id;
}
/** Returns the optional username. */
public String getUsername() {
return username;
}
}
@@ -0,0 +1,45 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.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.ResponseBody;
/**
* Success response for `GET_LOBBY_STATUS`.
*
* <p>Builds a response with a single `LOBBY` block containing `ID`, `NAME` and a nested `PLAYERS`
* collection with repeated `PLAYER` blocks (`USERNAME`, `READY`).
*/
public class GetLobbyStatusResponse extends SuccessResponse {
/**
* Create a new response for the provided {@link Lobby}.
*
* @param context request context to use for the response
* @param lobby lobby domain object containing id, name and players
*/
public GetLobbyStatusResponse(RequestContext context, Lobby lobby) {
super(
context,
ResponseBody.builder()
.block(
"LOBBY",
lb -> {
lb.param("ID", lobby.getId().value());
lb.param("NAME", lobby.getName());
lb.block(
"PLAYERS",
players -> {
for (String p : lobby.getPlayerNames()) {
players.block(
"PLAYER",
pb -> {
pb.param("USERNAME", p);
pb.param("READY", "false");
});
}
});
})
.build());
}
}
@@ -0,0 +1,83 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
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.User;
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;
/**
* Handler for the `JOIN_LOBBY` command.
*
* <p>Resolves the username from the session (requires {@link UserLoggedInCheck}), looks up the
* target lobby and attempts to add the user. On success an `+OK` response is dispatched; on failure
* an appropriate {@link ErrorResponse} with one of the error codes `LOBBY_NOT_FOUND`,
* `USER_NOT_LOGGED_IN` or `LOBBY_FULL_OR_ALREADY_IN` is returned.
*/
public class JoinLobbyHandler extends CommandHandler<JoinLobbyRequest> {
private final LobbyManager lobbyManager;
private final UserRegistry userRegistry;
/**
* Create a new {@link JoinLobbyHandler}.
*
* @param responseDispatcher dispatcher used to send responses back to the client
* @param lobbyManager manager providing lobby state and operations
* @param userRegistry registry to resolve session -> user mappings
*/
public JoinLobbyHandler(
ResponseDispatcher responseDispatcher,
LobbyManager lobbyManager,
UserRegistry userRegistry) {
super(responseDispatcher);
this.lobbyManager = lobbyManager;
this.userRegistry = userRegistry;
addCheck(new UserLoggedInCheck(userRegistry));
}
/**
* Execute the join-lobby request.
*
* @param request the parsed {@link JoinLobbyRequest}
*/
@Override
public void execute(JoinLobbyRequest request) {
LobbyId lid = LobbyId.of(request.getId());
var lobby = lobbyManager.getLobby(lid);
if (lobby == null) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
return;
}
var maybeUser = userRegistry.getBySessionId(request.getContext().sessionId());
if (maybeUser.isEmpty()) {
// UserLoggedInCheck should normally prevent this; keep safe fallback
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(),
"USER_NOT_LOGGED_IN",
"No user associated with session"));
return;
}
User user = maybeUser.get();
String username = user.getName();
boolean ok = lobbyManager.addPlayerToLobby(username, lid);
if (!ok) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(),
"LOBBY_FULL_OR_ALREADY_IN",
"Lobby full or user already in lobby"));
return;
}
responseDispatcher.dispatch(new OkResponse(request.getContext()));
}
}
@@ -0,0 +1,33 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_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;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor;
/**
* Parser for the `JOIN_LOBBY` command.
*
* <p>Expected request parameters:
*
* <ul>
* <li>`ID` (int) — numeric lobby identifier (required)
* </ul>
*
* <p>The parser builds a {@link JoinLobbyRequest} containing the parsed lobby id and the original
* request context.
*/
public class JoinLobbyParser implements CommandParser<JoinLobbyRequest> {
/**
* Parse the incoming primitive request into a {@link JoinLobbyRequest}.
*
* @param primitiveRequest the raw primitive request
* @return a {@link JoinLobbyRequest} with the parsed lobby id and context
*/
@Override
public JoinLobbyRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
int id = accessor.require("ID", Integer::parseInt);
return new JoinLobbyRequest(primitiveRequest.context(), id);
}
}
@@ -0,0 +1,34 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_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;
/**
* Request data for the `JOIN_LOBBY` command.
*
* <p>Contains the lobby id the client wants to join and inherits the {@link Request} contextual
* information.
*/
public class JoinLobbyRequest extends Request {
private final int id;
/**
* Create a new {@link JoinLobbyRequest}.
*
* @param context the request context
* @param id numeric lobby id to join
*/
public JoinLobbyRequest(RequestContext context, int id) {
super(context);
this.id = id;
}
/**
* Returns the lobby id requested by the client.
*
* @return numeric lobby id
*/
public int getId() {
return id;
}
}
@@ -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.
*
@@ -0,0 +1,131 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.GameEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.RoundManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.TurnManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
/** Represents a single lobby: id, name, players and an optional GameController. */
public class Lobby {
private static final Logger LOGGER = Logger.getLogger(Lobby.class.getName());
public enum AddResult {
NOT_ADDED,
ADDED,
ADDED_AND_STARTED
}
private final LobbyId id;
private final String name;
private final List<String> playerNames = new CopyOnWriteArrayList<>();
private volatile GameController gameController;
public Lobby(LobbyId id, String name) {
this.id = id;
this.name = name;
}
public LobbyId getId() {
return id;
}
public String getName() {
return name;
}
public List<String> getPlayerNames() {
return List.copyOf(playerNames);
}
/**
* Try to add a player to this lobby.
*
* @return true if added, false if already present or full
*/
public boolean addPlayer(String playerName, int maxPlayers) {
if (playerName == null) {
return false;
}
synchronized (playerNames) {
if (playerNames.contains(playerName)) {
return false;
}
if (playerNames.size() >= maxPlayers) {
return false;
}
playerNames.add(playerName);
return true;
}
}
public boolean removePlayer(String playerName) {
return playerNames.remove(playerName);
}
public void initGame(GameController controller) {
this.gameController = controller;
}
public GameController getGameController() {
return gameController;
}
/**
* Atomically add a player and, if the lobby reached {@code autoStartPlayers} and no game exists
* yet, create and start a game. The operation is synchronized on the internal player list to
* avoid races when multiple joins happen concurrently.
*
* @return {@link AddResult} indicating whether the player was added and if a game was started
*/
public AddResult addPlayerAndMaybeStart(
String playerName, int maxPlayers, int autoStartPlayers, int defaultStartChips) {
if (playerName == null) {
return AddResult.NOT_ADDED;
}
synchronized (playerNames) {
if (playerNames.contains(playerName)) {
return AddResult.NOT_ADDED;
}
if (playerNames.size() >= maxPlayers) {
return AddResult.NOT_ADDED;
}
playerNames.add(playerName);
// If threshold reached and no game running, create and start game here
if (gameController == null && playerNames.size() == autoStartPlayers) {
try {
GameState state = new GameState();
GameEngine engine =
new GameEngine(
state,
new RuleEngine(new ArrayList<>()),
new RoundManager(),
new TurnManager());
GameController game = new GameController(engine);
for (String p : playerNames) {
game.addPlayer(PlayerId.of(p), defaultStartChips);
}
game.startGame();
this.gameController = game;
LOGGER.info(() -> "Auto-started game in lobby " + id.value());
return AddResult.ADDED_AND_STARTED;
} catch (RuntimeException e) {
LOGGER.log(Level.WARNING, "Auto-start failed for lobby " + id.value(), e);
return AddResult.ADDED;
}
}
return AddResult.ADDED;
}
}
}
@@ -0,0 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
/** Listener interface for lobby lifecycle events. */
public interface LobbyEventListener {
/** Called when a game is automatically started in the given lobby. */
void onGameStarted(LobbyId lobbyId);
}
@@ -0,0 +1,8 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
/** Typesafe wrapper for lobby IDs (1-8). */
public record LobbyId(int value) {
public static LobbyId of(int value) {
return new LobbyId(value);
}
}
@@ -0,0 +1,144 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby.AddResult;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
/** Manages dynamic creation of up to 8 lobbies and maps players to lobbies. */
public class LobbyManager {
private static final int MAX_LOBBIES = 8;
private static final int DEFAULT_MAX_PLAYERS = 4;
private static final int AUTO_START_PLAYERS = 4;
private static final int DEFAULT_START_CHIPS = 20000;
private final Map<LobbyId, Lobby> activeLobbies = new ConcurrentHashMap<>();
private final Map<String, LobbyId> playerToLobby = new ConcurrentHashMap<>();
private final List<LobbyEventListener> listeners = new CopyOnWriteArrayList<>();
private final int maxPlayersPerLobby;
private static final Logger LOGGER = Logger.getLogger(LobbyManager.class.getName());
public LobbyManager() {
this(DEFAULT_MAX_PLAYERS);
}
public LobbyManager(int maxPlayersPerLobby) {
this.maxPlayersPerLobby = maxPlayersPerLobby;
}
/** Create a new lobby with an automatic id (1..8). Returns null if none available. */
public synchronized LobbyId createNewLobby(String name) {
if (activeLobbies.size() >= MAX_LOBBIES) {
return null;
}
for (int i = 1; i <= MAX_LOBBIES; i++) {
LobbyId id = LobbyId.of(i);
if (!activeLobbies.containsKey(id)) {
Lobby lobby = new Lobby(id, name == null ? ("Room " + i) : name);
activeLobbies.put(id, lobby);
return id;
}
}
return null;
}
public Lobby getLobby(LobbyId id) {
if (id == null) {
return null;
}
return activeLobbies.get(id);
}
public Lobby getLobbyByUsername(String username) {
LobbyId id = playerToLobby.get(username);
if (id == null) {
return null;
}
return activeLobbies.get(id);
}
public boolean addPlayerToLobby(String username, LobbyId lobbyId) {
Lobby lobby = getLobby(lobbyId);
if (lobby == null) {
return false;
}
AddResult result =
lobby.addPlayerAndMaybeStart(
username, maxPlayersPerLobby, AUTO_START_PLAYERS, DEFAULT_START_CHIPS);
if (result != AddResult.NOT_ADDED) {
playerToLobby.put(username, lobbyId);
if (result == AddResult.ADDED_AND_STARTED) {
LOGGER.info(
() ->
"Lobby "
+ lobbyId.value()
+ " reached "
+ AUTO_START_PLAYERS
+ " players; game started.");
notifyGameStarted(lobbyId);
}
return true;
}
return false;
}
public void addListener(LobbyEventListener listener) {
listeners.add(listener);
}
public void removeListener(LobbyEventListener listener) {
listeners.remove(listener);
}
private void notifyGameStarted(LobbyId lobbyId) {
for (LobbyEventListener l : listeners) {
try {
l.onGameStarted(lobbyId);
} catch (RuntimeException e) {
LOGGER.warning(
() ->
"Listener threw while handling game-start for lobby "
+ lobbyId.value());
}
}
}
public boolean removePlayer(String username) {
LobbyId id = playerToLobby.remove(username);
if (id == null) {
return false;
}
Lobby lobby = activeLobbies.get(id);
if (lobby == null) {
return false;
}
boolean removed = lobby.removePlayer(username);
// If lobby becomes empty, remove it
if (lobby.getPlayerNames().isEmpty()) {
activeLobbies.remove(id);
}
return removed;
}
public Collection<Lobby> getAllLobbies() {
return activeLobbies.values();
}
/**
* Apply the given action to every player username in the lobby identified by {@code lobbyId}.
* This is a small helper that keeps iteration logic centralized and avoids leaking internal
* collections to callers.
*/
public void broadcast(LobbyId lobbyId, java.util.function.Consumer<String> action) {
Lobby lobby = getLobby(lobbyId);
if (lobby == null) {
return;
}
for (String username : lobby.getPlayerNames()) {
action.accept(username);
}
}
}
@@ -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);
}
}
}