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

This commit is contained in:
Jona Walpert
2026-04-12 09:12:46 +02:00
187 changed files with 22261 additions and 361 deletions
@@ -0,0 +1,129 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ChatClient;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatBoxController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import org.jspecify.annotations.Nullable;
/**
* responsible for the transferring of messages from the server to the ChatModel or from the
* ChatViewController to the server
*/
public class ChatController {
private final String username;
private final ChatClient chatClient;
public ChatBoxController getChatBoxController() {
return chatBoxController;
}
private final ChatBoxController chatBoxController;
private int lobbyId = -1;
private final Timer timer;
public record ChatKey(ChatType type, @Nullable String targetUser) {
public ChatKey(ChatType type) {
this(type, null);
}
}
public Map<ChatKey, ChatModel> getChatModelMap() {
return chatModelMap;
}
private Map<ChatKey, ChatModel> chatModelMap;
private static final long REFRESH_TIME = 1000;
/**
* Constructor, adds TimerTask to be sent to the server regularly
*
* @param username
* @param clientService
*/
public ChatController(String username, ClientService clientService) {
this.username = username;
chatClient = new ChatClient(clientService);
chatModelMap = new LinkedHashMap<>();
this.chatBoxController = new ChatBoxController(username, this);
this.timer = new Timer();
timer.schedule(
new TimerTask() {
@Override
public void run() {
receiveMessage();
}
},
0,
REFRESH_TIME);
}
/**
* Method to be activated, if a lobby has be chosen. It will update the UI and add a new
* ChatModel to hold the Data for the Lobby Chat
*
* @param lobbyId
*/
public void setLobbyChat(int lobbyId) {
this.lobbyId = lobbyId;
ChatModel lobbyChatModel = new ChatModel(ChatType.LOBBY, username, lobbyId, null);
chatModelMap.put(new ChatKey(ChatType.LOBBY), lobbyChatModel);
this.chatBoxController.addChatTab("Lobby", lobbyChatModel);
}
/** method to get all messages from the server */
public void receiveMessage() {
List<Message> newMessages = chatClient.getMessages();
if (!newMessages.isEmpty()) {
for (Message msg : newMessages) {
switch (msg.getMessageType()) {
case ChatType.GLOBAL:
chatModelMap.get(new ChatKey(ChatType.GLOBAL)).addMessage(msg);
break;
case ChatType.LOBBY:
if (msg.lobbyId == lobbyId) {
chatModelMap
.computeIfAbsent(
new ChatKey(ChatType.LOBBY),
(_key) ->
new ChatModel(
ChatType.LOBBY,
username,
msg.lobbyId,
null))
.addMessage(msg);
}
break;
case ChatType.WHISPER:
if (msg.target.equals(username)) {
if (chatModelMap.containsKey(
new ChatKey(ChatType.WHISPER, msg.sender))) {
chatModelMap
.get(new ChatKey(ChatType.WHISPER, msg.sender))
.addMessage(msg);
} else {
chatBoxController.addWhisperChat(msg.sender);
}
}
break;
}
}
}
}
/**
* method to send a message to the server
*
* @param message
*/
public void onSendToNetwork(Message message) {
chatClient.sendMessage(message);
}
}
@@ -0,0 +1,85 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
import java.util.ArrayList;
import java.util.function.Consumer;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
/**
* ChatModel, stores the data for a specific chat
*
* <p>Holds the current state of a chat
*/
public class ChatModel {
private ArrayList<Consumer<Message>> listeners = new ArrayList<>();
public ArrayList<Message> messages;
private final ChatType chattype;
/** The person currently using this client */
public final String username;
/** The person to send the message to If the chat is a whisper chat */
private final String target;
private final IntegerProperty count;
public int lobbyId;
/**
* Constructs a new ChatModel for a specific chat type.
*
* @param chattype The type of chat (e.g., GLOBAL, LOBBY, or WHISPER).
* @param username The username of the current user.
* @param lobbyId The ID of the lobby, or -1 if not applicable.
* @param target The username of the whisper recipient, or null for other chat types.
*/
public ChatModel(ChatType chattype, String username, int lobbyId, String target) {
this.messages = new ArrayList<Message>();
this.chattype = chattype;
this.username = username;
this.count = new SimpleIntegerProperty(0);
this.lobbyId = lobbyId;
this.target = target;
}
/**
* Returns the type of chat this model represents.
*
* @return The {@link ChatType}.
*/
public ChatType getChattype() {
return chattype;
}
/**
* Adds a new message to the history and notifies all registered listeners. This method is
* synchronized to ensure thread safety when updating the message list.
*
* @param msg The {@link Message} to be added.
*/
public synchronized void addMessage(Message msg) {
messages.add(msg);
listeners.stream().forEach((l) -> l.accept(messages.getLast()));
}
/**
* Registers a listener to be notified whenever a new message is added to this model.
*
* @param listener A {@link Consumer} that processes the new {@link Message}.
*/
public void addListener(Consumer<Message> listener) {
this.listeners.add(listener);
}
/**
* Returns the target user for this chat, primarily used for whispers.
*
* @return The target username or null.
*/
public String getTarget() {
return target;
}
}
@@ -0,0 +1,8 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
/** Describing the Type of the Chat */
public enum ChatType {
GLOBAL,
LOBBY,
WHISPER
}
@@ -0,0 +1,209 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
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.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import org.jspecify.annotations.NonNull;
/** Message Object for internal handling of Chat-Messages */
public class Message {
private final ChatType type;
private final String message;
public String sender;
public String timestamp;
public int lobbyId = 0;
public String target = null;
/**
* Constructs a Message with a provided timestamp. Typically used when reconstructing messages
* received from the server. Used for Messages in the Lobby Chat.
*
* @param lobbyId The ID of the lobby, or -1 if not applicable.
* @param sender The username of the message creator.
* @param timestamp The formatted time string (e.g., "HH:mm").
* @param message The actual text content of the message.
*/
private Message(int lobbyId, String sender, String timestamp, String message) {
this.type = ChatType.LOBBY;
this.lobbyId = lobbyId;
this.sender = sender;
this.target = null;
this.timestamp = timestamp;
this.message = message;
}
/**
* Constructs a Message with a provided timestamp. Typically used when reconstructing messages
* received from the server. Used for Messages in the WHISPER and GLOBAL Chat.
*
* @param type The chat category (e.g., GLOBAL or WHISPER).
* @param sender The username of the message creator.
* @param timestamp The formatted time string (e.g., "HH:mm").
* @param message The actual text content of the message.
*/
private Message(ChatType type, String sender, String target, String timestamp, String message) {
this.type = type;
this.lobbyId = -1;
this.sender = sender;
this.target = target;
this.timestamp = timestamp;
this.message = message;
}
/**
* Constructs a new Message for the current user. Automatically generates a timestamp based on
* the local system time ("HH:mm").
*
* @param type The chat category (e.g., GLOBAL, LOBBY, or WHISPER).
* @param lobbyId The ID of the lobby, or -1 if not applicable.
* @param sender The username of the current user.
* @param target The username of the recipient (for whispers).
* @param message The actual text content to be sent.
*/
public Message(ChatType type, int lobbyId, String sender, String target, String message) {
this.type = type;
this.lobbyId = lobbyId;
this.sender = sender;
this.target = target;
this.message = message;
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm");
this.timestamp = now.format(formatter);
}
/**
* Returns the text content of the message.
*
* @return The message string.
*/
public String getMessage() {
return message;
}
/**
* Returns the type of chat this message belongs to.
*
* @return The {@link ChatType}.
*/
public ChatType getMessageType() {
return type;
}
/**
* Formats the message object into a string representation compatible with the network protocol
* arguments.
*
* @return A formatted string containing all message attributes for server transmission.
*/
public String toArgsString() {
String gameIdString = "";
if (lobbyId >= 0) {
gameIdString = " GAME=" + lobbyId;
} else {
gameIdString = " GAME='-1'";
}
return String.format(
"TYPE=%s%s USER='%s' TARGET='%s' TIME='%s' TEXT='%s'",
this.type.toString(),
gameIdString,
this.sender,
this.target,
this.timestamp,
this.message);
}
/**
* Parses a list of network request parameters to reconstruct a Message object. Handles
* different chat types (GLOBAL, LOBBY, WHISPER) and their specific requirements.
*
* @param parameters A list of {@link RequestParameter} received from the network.
* @return A new {@link Message} instance populated with the parsed data.
*/
public static Message toMessageReqPars(List<RequestParameter> parameters) {
String typeString = getParString(parameters, "TYPE");
ChatType type = ChatType.valueOf(typeString);
return switch (type) {
case GLOBAL ->
new Message(
ChatType.GLOBAL,
getParString(parameters, "USER"),
null,
getParString(parameters, "TIME"),
getParString(parameters, "TEXT"));
case LOBBY ->
new Message(
Integer.parseInt(getParString(parameters, "GAME")),
getParString(parameters, "USER"),
getParString(parameters, "TIME"),
getParString(parameters, "TEXT"));
case WHISPER ->
new Message(
ChatType.WHISPER,
getParString(parameters, "USER"),
getParString(parameters, "TARGET"),
getParString(parameters, "TIME"),
getParString(parameters, "TEXT"));
};
}
/**
* Helper method to extract a specific parameter value by its key.
*
* @param parameters The list of parameters to search.
* @param keyString The key to look for.
* @return The value associated with the key.
* @throws RuntimeException if the key is not found.
*/
private static @NonNull String getParString(
List<RequestParameter> parameters, String keyString) {
return getParString(parameters, keyString, null);
}
/**
* Helper method to extract a specific parameter value by its key, with a fallback default
* value.
*
* @param parameters The list of parameters to search.
* @param keyString The key to look for.
* @param defaultVal The value to return if the key is missing.
* @return The found value or the default value.
*/
private static @NonNull String getParString(
List<RequestParameter> parameters, String keyString, String defaultVal) {
Optional<String> parOption =
parameters.stream()
.filter((p) -> keyString.equals(p.key()))
.findFirst()
.map(RequestParameter::value);
if (parOption.isEmpty()) {
if (defaultVal == null) {
throw new RuntimeException("No " + keyString + " found");
} else {
return defaultVal;
}
}
return parOption.get();
}
/**
* Converts the message object into a network response body using the provided builder.
*
* @param builder The {@link ResponseBodyBuilder} used to construct the response.
* @return The built {@link ResponseBody} containing the message data.
*/
public ResponseBody toResponse(ResponseBodyBuilder builder) {
builder.param("TYPE", type.name());
builder.param("GAME", lobbyId);
builder.param("USER", this.sender);
if (target != null) {
builder.param("TARGET", target);
}
builder.param("TIME", this.timestamp);
builder.param("TEXT", this.message);
return builder.build();
}
}
@@ -0,0 +1,54 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
/** Represents a playing card with a value and suit. */
public class Card {
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);
}
}
@@ -0,0 +1,20 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
import java.util.ArrayList;
import java.util.List;
/**
* Represents the current state of the poker game, including the phase, pot size, current bet,
* dealer position, active player, community cards, and player information.
*/
public class GameState {
public String phase;
public int pot;
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<>();
}
@@ -0,0 +1,193 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
import java.util.ArrayList;
import java.util.List;
/**
* Represents a player in the poker game, including their name, chip count, current bet, state
* (e.g., "active", "folded"), and their hole cards.
*/
public class Player {
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
}
@@ -0,0 +1,72 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.ArrayList;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* The ChatClient class is responsible for sending messages to the server and retrieving messages
* from the server. It uses the ClientService to send commands and receive responses from the
* server.
*/
public class ChatClient {
private final ClientService clientService;
private final Logger logger;
/**
* Constructs a ChatClient with the given ClientService for communication.
*
* @param clientService The ClientService instance used to send commands and receive responses
* from the server.
*/
public ChatClient(ClientService clientService) {
this.clientService = clientService;
this.logger = LogManager.getLogger(ChatClient.class);
}
/**
* Send a Message to the server by converting it to a string format and sending a "SEND_MESSAGE"
* command with the message content as arguments.
*
* @param message The Message object to be sent to the server.
*/
public void sendMessage(Message message) {
String request = "SEND_MESSAGE " + message.toArgsString();
logger.info("Writing to server: " + request);
clientService.processCommand(request);
}
/**
* Retrieve messages from the server by first sending a "GET_MESSAGE_COUNT" command to determine
* how many messages are available and then sending "GET_NEXT_MESSAGE" commands in a loop to
* retrieve each message. The retrieved messages are parsed into Message objects and returned as
* a list.
*
* @return A list of Message objects representing the messages retrieved from the server.
*/
public List<Message> getMessages() {
logger.info("Asking server for new messages");
List<RequestParameter> countStr =
ClientService.convertToRequestParameters(
clientService.processCommand("GET_MESSAGE_COUNT"));
RequestParameter countRes = countStr.getFirst();
if (!countRes.key().equals("COUNT")) {
logger.error("Not the right response from server");
}
int count = Integer.parseInt(countRes.value());
logger.info("Got " + count + " messages");
ArrayList<Message> messages = new ArrayList<>();
for (int i = 0; i < count; i++) {
List<RequestParameter> msgRes =
ClientService.convertToRequestParameters(
clientService.processCommand("GET_NEXT_MESSAGE"));
Message msg = Message.toMessageReqPars(msgRes);
messages.add(msg);
}
return messages;
}
}
@@ -0,0 +1,244 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.RawPacket;
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TcpTransport;
import java.io.IOException;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* The ClientService class is responsible for managing the connection to the server, sending
* commands, and receiving responses. It uses a TcpTransport to communicate with the server and an
* ExecutorService to handle asynchronous requests.
*/
public class ClientService {
private final TcpTransport clienttcptransport;
private final Socket socket;
private final ExecutorService executor;
private final boolean offlineMode;
public static ArrayList<String> response;
private final AtomicInteger idGenerator;
private Logger logger;
/**
* Constructs a ClientService with the given server IP and port. It establishes a socket
* connection to the server and initializes the TcpTransport and ExecutorService for
* communication.
*
* @param ip The IP address of the server to connect to.
* @param port The port number of the server to connect to.
*/
public ClientService(String ip, int port) {
this.idGenerator = new AtomicInteger(0);
this.logger = LogManager.getLogger(ClientService.class);
this.offlineMode = false;
try {
socket = new Socket(ip, port);
clienttcptransport = new TcpTransport(socket);
logger.info("Connected to server at " + ip);
} catch (IOException i) {
throw new RuntimeException(i);
}
executor = Executors.newSingleThreadExecutor();
}
/**
* Constructs a ClientService in offline mode. No network connection will be attempted and calls
* to processCommand will throw a RuntimeException.
*
* @param offline true to create an offline (no-network) client service
*/
public ClientService(boolean offline) {
this.idGenerator = new AtomicInteger(0);
this.offlineMode = offline;
this.socket = null;
this.clienttcptransport = null;
this.executor = Executors.newSingleThreadExecutor();
}
/** Returns true if this ClientService is running in offline mode (no network). */
public boolean isOffline() {
return offlineMode;
}
static Pattern responseRex =
Pattern.compile(
"(?<key>\\w+)=(('(?<string>([^']|\\')+)')|(?<primVal>[+-]?[\\d\\w:]+))");
/**
* Removes escape characters from a string, specifically converting escaped single quotes (\')
* back to regular single quotes (').
*
* @param input The escaped string to process.
* @return The unescaped string.
*/
private static String unescape(String input) {
return input.replaceAll("\\\\'", "'");
}
/**
* Converts a list of raw string parameters into a list of {@link RequestParameter} objects. It
* uses a regex matcher to distinguish between quoted strings (which are unescaped) and
* primitive values.
*
* @param input A list of raw strings to be parsed.
* @return A list of parsed {@link RequestParameter} objects.
* @throws RuntimeException if a parameter does not match the expected format.
*/
public static List<RequestParameter> convertToRequestParameters(List<String> input) {
return input.stream()
.map((String parString) -> responseRex.matcher(parString))
.filter(Matcher::matches)
.map(
(m) -> {
if (!(m.group("string") == null)) {
return new RequestParameter(
m.group("key"), unescape(m.group("string")));
} else if (!(m.group("primVal") == null)) {
return new RequestParameter(m.group("key"), m.group("primVal"));
} else {
throw new RuntimeException();
}
})
.toList();
}
/**
* Sends a command to the server and processes the multi-line response. It handles the protocol
* handshake (expecting +OK), strips leading tabs from response lines, and collects them until
* the "END" marker is reached.
*
* @param message The raw command string to be sent to the transport layer.
* @return A list of response lines received from the server (excluding protocol markers).
* @throws RuntimeException if the server responds with an error or if a communication failure
* occurs.
*/
protected List<String> processCommand(String message) {
List<String> response = new ArrayList<>();
sendRequest(
() -> {
try {
writeToTransport(message);
String responseText = null;
responseText = clienttcptransport.read().payload();
logger.info("Raw message '" + responseText + "'");
Boolean success = null;
int count = 0;
for (String line : responseText.split("\n")) {
if (success == null) {
if ("+OK".equals(line)) {
success = true;
continue;
} else if (("-ERROR").equals(responseText)) {
success = false;
}
continue;
} else if ("END".equals(line)) {
break;
}
line = line.replaceFirst("^\t", "");
response.add(line);
}
if (success != null && success) {
return;
} else {
throw new RuntimeException("Error in " + message + ": " + response);
}
} catch (Exception e) {
throw getRuntimeException(e);
}
});
return response;
}
/**
* Helper method to send a request to the server using the ExecutorService. It submits the
* request as a Runnable task and waits for its completion. If the task is interrupted or
* encounters an execution exception, it throws a RuntimeException with the appropriate cause.
*
* @param request The Runnable task representing the request to be sent to the server.
*/
private void sendRequest(Runnable request) {
Future<?> future = executor.submit(request);
try {
future.get();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw getRuntimeException(e);
}
}
/**
* Helper method to extract the cause of an exception and return it as a RuntimeException. If
* the cause is null, it returns the original exception as a RuntimeException. If the cause is
* already a RuntimeException, it returns it directly. Otherwise, it wraps the cause in a new
* RuntimeException and returns it.
*
* @param e The exception from which to extract the cause.
* @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;
}
re = new RuntimeException(reason);
return re;
}
/**
* Closes the socket connection to the server and shuts down the ExecutorService. It also closes
* the TcpTransport used for communication. If any IOException occurs during this process, it
* prints the exception to the console.
*/
public void closeSocket() {
try {
executor.shutdown();
clienttcptransport.close();
socket.close();
logger.info("Socket closed");
} catch (IOException j) {
logger.debug(j);
}
}
/**
* Method to write with the tcp transport to the server
*
* @param s - Message to be sent
* @throws IOException
*/
private void writeToTransport(String s) throws IOException {
int id = this.idGenerator.incrementAndGet();
this.clienttcptransport.write(new RawPacket(id, s));
}
public void ping() {
processCommand("PING");
}
}
@@ -0,0 +1,37 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
/**
* The CoreClient class provides basic functionalities for communicating with the server, such as
* sending a ping command to check connectivity and logging in with a username. It uses the
* ClientService to send commands and receive responses from the server.
*/
public class CoreClient {
private final ClientService clientService;
/**
* Constructs a CoreClient with the given ClientService for communication.
*
* @param clientservice The ClientService instance used to send commands and receive responses
* from the server.
*/
public CoreClient(ClientService clientservice) {
this.clientService = clientservice;
}
/**
* Sends a "PING" command to the server to check connectivity. The server should respond with a
* "PONG" message if the connection is successful.
*/
public void ping() {
clientService.processCommand("PING");
}
/**
* Logs in to the server with the given username by sending a "LOGIN" command.
*
* @param user The username to log in with.
*/
public void login(String user) {
clientService.processCommand("LOGIN USERNAME=" + user);
}
}
@@ -0,0 +1,312 @@
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;
/**
* The GameClient class is responsible for communicating with the server to retrieve the current
* game state. It sends a command to the server and parses the response into a structured GameState
* object.
*/
public class GameClient {
private final ClientService client;
private final int gameId;
/**
* Constructs a GameClient with the given ClientService for communication.
*
* @param client The ClientService instance used to send commands and receive responses from the
* server.
*/
public GameClient(ClientService client, int gameId) {
this.client = client;
this.gameId = gameId;
}
/**
* 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, as parsed
*/
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);
}
/**
* Send a RAISE command to the server to indicate that the player wants to raise
*
* @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 parse(String input) {
GameState state = new GameState();
ParserContext ctx = new ParserContext(state);
for (String raw : input.split("\n")) {
String line = raw.trim();
if (shouldSkip(line)) {
continue;
}
if (handleGlobal(line, ctx)) {
continue;
}
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;
};
}
}
@@ -0,0 +1,72 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
/**
* 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
* sending appropriate commands to the server and processing the responses.
*/
public class LobbyClient {
private final ClientService client;
/**
* Constructs a LobbyClient with the given ClientService for communication.
*
* @param client The ClientService instance used to send commands and receive responses from the
* server.
*/
public LobbyClient(ClientService client) {
this.client = client;
}
public ClientService getClientService() {
return client;
}
/**
* Fetch the current status of the lobby with the given id from the server.
*
* @param lobbyId The id of the lobby to fetch the status for.
* @return A string representing the current status of the lobby, as returned by the server.
*/
public String fetchLobbyStatusString(int lobbyId) {
return client.processCommand("GET_LOBBY_STATUS ID=" + lobbyId).getFirst();
}
/**
* Request the server to create a new lobby and return the id of the newly created lobby.
*
* @return The id of the newly created lobby, as returned by the server.
*/
public int createLobby() {
String response = client.processCommand("CREATE_LOBBY").getFirst();
return Integer.parseInt(response);
}
/**
* Request the server to return the id of the lobby that the client is currently in.
*
* @return The id of the lobby that the client is currently in, as returned by the server.
*/
public int getLobbyId() {
String response = client.processCommand("GET_LOBBY_ID").getFirst();
return Integer.parseInt(response);
}
/**
* Request the server to join the lobby with the given id.
*
* @param lobbyId The id of the lobby to join.
*/
public void joinLobby(int lobbyId) {
client.processCommand("JOIN_LOBBY ID=" + lobbyId);
}
/**
* Logs in to the server with the given username by sending a "LOGIN" command.
*
* @param user The username to log in with.
*/
public void login(String user) {
client.processCommand("LOGIN USERNAME=" + user);
}
}
@@ -0,0 +1,112 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatType;
import java.io.IOException;
import java.net.URL;
import java.util.List;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.control.MenuButton;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
public class ChatBoxController {
private String username;
private ChatController chatController;
@FXML private VBox chatBox;
@FXML private TabPane chatTabPane;
@FXML private MenuButton addWhisperChatButton;
private FXMLLoader fxmlLoader;
@FXML private List<MenuItem> whisperUsers;
private String ressource = "/ui-structure/components/chatui/chattab.fxml";
public ChatBoxController(String username, ChatController chatController) {
this.username = username;
this.chatController = chatController;
}
/**
* Initializes the chat interface by creating the global chat model and adding the corresponding
* "GLOBAL" tab to the interface. It also registers the global chat in the {@link
* ChatController}'s model map.
*/
@FXML
public void initialize() {
ChatModel globalChatModel = new ChatModel(ChatType.GLOBAL, username, -1, null);
chatController
.getChatModelMap()
.put(new ChatController.ChatKey(ChatType.GLOBAL), globalChatModel);
addChatTab("GLOBAL", globalChatModel);
// TODO: Button to add new Whisper Chat
}
/**
* Adds a specific user to the list of available whisper targets. Creates a new menu item for
* the user and defines the action to open a private chat tab when selected.
*
* @param targetUserName The username of the person to be added to the whisper list.
*/
public void addWhisperUser(String targetUserName) {
MenuItem menuItem = new MenuItem(targetUserName);
whisperUsers.add(menuItem);
addWhisperChatButton.getItems().add(menuItem);
menuItem.setOnAction(event -> addWhisperChat(targetUserName));
}
/**
* Creates a new private (whisper) chat model for a specific target user, registers it within
* the chat system, and opens a new chat tab.
*
* @param target The username of the recipient for the private messages.
*/
public void addWhisperChat(String target) {
ChatModel chatModel = new ChatModel(ChatType.WHISPER, username, -1, target);
chatController
.getChatModelMap()
.put(new ChatController.ChatKey(ChatType.WHISPER, target), chatModel);
addChatTab(target, chatModel);
}
/**
* Dynamically loads a new chat tab from an FXML resource and attaches it to the TabPane. It
* initializes a {@link ChatViewController} for the tab and sets up a listener to display
* incoming messages in real-time.
*
* @param title The title to be displayed on the tab header.
* @param chatModel The {@link ChatModel} containing the data and logic for this specific chat.
* @throws RuntimeException If the FXML resource for the chat tab cannot be loaded.
*/
public void addChatTab(String title, ChatModel chatModel) {
URL resource = getClass().getResource(ressource);
FXMLLoader fxmlLoader = new FXMLLoader(resource);
try {
ChatViewController chatViewController =
new ChatViewController(this.chatController, chatModel, this.username);
fxmlLoader.setController(chatViewController);
Node load = fxmlLoader.load();
VBox.setVgrow(load, Priority.ALWAYS);
VBox vbox = new VBox();
VBox.setVgrow(vbox, Priority.ALWAYS);
vbox.getChildren().add(load);
chatModel.addListener((msg) -> chatViewController.showMessage(msg));
Tab newChat = new Tab(title, vbox);
this.chatTabPane.getTabs().add(newChat);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -1,90 +0,0 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
/**
* Controller-Klasse für das Chat-System innerhalb der Spieloberfläche.
*
* <p>Verwaltet das Anzeigen von Chatnachrichten, das Eingabefeld für eigene Nachrichten sowie den
* Senden-Button. Unterstützt drei Arten von Nachrichten: - Player-to-Player (Privat) - Lobby-Chat
* (Raum) - Globaler Chat (Serverweit)
*
* <p>Nachrichten werden in einem {@link VBox}-Container als {@link Label} angezeigt. Eigene
* Nachrichten werden über {@link #onSendToNetwork(String)} an das Netzwerkprotokoll weitergeleitet,
* während eingehende Nachrichten über {@link #receiveMessage(String, String)} verarbeitet und
* angezeigt werden.
*
* <p>Hinweis: Einige TODOs stehen in der zugehörigen FXML-Datei
*/
public class ChatController {
@FXML private VBox chatVBox;
@FXML private TextField inputField;
@FXML private Button sendButton;
@FXML private ScrollPane chatScrollPane;
private static final int CHAT_PADDING = 20;
/** Initialisiert den ChatController nach dem Laden der FXML. */
public void initialize() {
inputField.setOnAction(event -> sendMessage());
chatScrollPane.vvalueProperty().bind(chatVBox.heightProperty());
}
/** Standardkonstruktor. Wird von FXML verwendet. */
public ChatController() {
// default constructor for FXML
}
/**
* Diese Methode wird vom Senden-Button oder Enter ausgelöst. Sie gibt die eigene Nachricht an
* das Netzwerkprotokoll weiter.
*/
@FXML
private void sendMessage() {
String message = inputField.getText().trim();
if (!message.isEmpty()) {
inputField.clear();
// Hier wird die eigene Nachricht ans Netzwerkprotokoll übergeben
onSendToNetwork(message);
}
}
/**
* Diese Funktion muss vom Netzwerkprotokoll aufgerufen werden, wenn eine neue Nachricht von
* einem anderen Spieler kommt.
*
* @param player Name des Spielers
* @param message Nachricht des Spielers
*/
public void receiveMessage(String player, String message) {
String time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));
Label label = new Label("[" + time + "] " + player + ": " + message);
label.getStyleClass().add("info-text");
label.setWrapText(true); // Zeilenumbruch aktivieren
label.maxWidthProperty().bind(chatVBox.widthProperty().subtract(CHAT_PADDING));
chatVBox.getChildren().add(label);
}
/**
* Schnittstelle zum Netzwerkprotokoll. Diese Funktion wird automatisch aufgerufen, wenn der
* Benutzer eine eigene Nachricht sendet.
*
* @param message Nachricht, die der Benutzer abgeschickt hat
*/
public void onSendToNetwork(String message) {
// TODO: Netzwerkcode einfügen
receiveMessage("Du", message);
}
}
@@ -0,0 +1,102 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import java.net.URL;
import java.util.ResourceBundle;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/** Responsible for the presentation of the ChatModel to the Client */
public class ChatViewController implements Initializable {
@FXML private Button sendButton;
@FXML private TextField inputField;
@FXML private VBox chatInterfaceVBox;
@FXML private HBox controlBar;
@FXML private ScrollPane scrollPane;
@FXML private VBox chat;
private final ChatModel chatModel;
private final String username;
private final ChatController controller;
private static final int CHAT_PADDING = 20;
public ChatViewController(ChatController chatController, ChatModel chatModel, String username) {
this.controller = chatController;
this.username = username;
this.chatModel = chatModel;
}
/**
* Initializes the controller after the FXML root element has been processed. Sets up event
* handlers for sending messages via the input field or button and ensures the ScrollPane
* automatically scrolls to the bottom when new messages are added.
*
* @param location The location used to resolve relative paths for the root object.
* @param resourceBundle The resources used to localize the root object.
*/
@Override
public void initialize(URL location, ResourceBundle resourceBundle) {
inputField.setOnAction(event -> sendMessage());
sendButton.setOnAction(event -> sendMessage());
scrollPane.vvalueProperty().bind(chat.heightProperty());
}
/**
* Retrieves the text from the input field, creates a new {@link Message} object using the
* current model state, and passes it to the {@link ChatController} for network transmission.
* The input field is cleared after sending.
*/
public void sendMessage() {
String message = inputField.getText().trim();
if (!message.isEmpty()) {
inputField.clear();
Message msg =
new Message(
chatModel.getChattype(),
chatModel.lobbyId,
username,
chatModel.getTarget(),
message);
controller.onSendToNetwork(msg);
}
}
/**
* Displays a message in the chat interface. This method creates a new Label for the message
* text and adds it to the message container. It uses {@link Platform#runLater(Runnable)} to
* ensure the UI update happens on the JavaFX Application Thread.
*
* @param msg The {@link Message} object containing the content and metadata to display.
*/
public void showMessage(Message msg) {
Platform.runLater(
() -> {
String msgText =
String.format(
"[%s] %s: %s", msg.timestamp, msg.sender, msg.getMessage());
Label label = new Label(msgText);
label.getStyleClass().add("info-text");
label.setWrapText(true);
label.maxWidthProperty().bind(chat.widthProperty().subtract(CHAT_PADDING));
chat.getChildren().add(label);
});
}
}
@@ -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();
}
}
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import java.io.IOException;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
@@ -17,13 +18,19 @@ import javafx.stage.Stage;
*/
public class CasinoGameUI extends Application {
private static ClientService clientService;
private static final int DEFAULT_WIDTH = 1200;
private static final int DEFAULT_HEIGHT = 800;
/** 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.
@@ -36,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));
@@ -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");
}
}
@@ -30,6 +30,17 @@ public class Casinomainui extends Application {
* @throws IOException If loading the FXML fails.
*/
public void start(Stage stage) throws IOException {
// If the launcher passed an address argument (ip:port), expose it as
// system properties so controllers can read it without embedding defaults.
var raw = getParameters().getRaw();
if (raw != null && raw.size() > 0) {
String arg = raw.get(0);
String[] parts = arg.split(":", 2);
if (parts.length == 2) {
System.setProperty("casono.server.host", parts[0]);
System.setProperty("casono.server.port", parts[1]);
}
}
FXMLLoader fxmlLoader =
new FXMLLoader(getClass().getResource("/ui-structure/Casinomainui.fxml"));
Scene scene = new Scene(fxmlLoader.load(), SCENE_WIDTH, SCENE_HEIGHT);
@@ -1,9 +1,14 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
@@ -23,10 +28,13 @@ public class CasinomainuiController {
@FXML private Rectangle greenBox;
@FXML private Button exitbutton;
@FXML private VBox casinoTable;
@FXML private TextField usernameField;
@FXML private Button loginButton;
private LobbyButtonTranslationManager translationManager;
private LobbyButtonGridManager gridManager;
private int nextButtonId = 1;
private LobbyClient lobbyClient;
/** Default constructor for dependency injection by FXMLLoader. */
public CasinomainuiController() {
@@ -41,8 +49,63 @@ public class CasinomainuiController {
logoView.setImage(new Image(getClass().getResource("/images/logo.png").toExternalForm()));
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
}
gridManager =
new LobbyButtonGridManager(new javafx.scene.layout.GridPane(), translationManager);
new LobbyButtonGridManager(
new javafx.scene.layout.GridPane(), translationManager, clientService);
// LobbyClient will use the provided ClientService; in offline mode calls will
// fail with RuntimeException
lobbyClient = new LobbyClient(clientService);
casinoTable.getChildren().clear();
casinoTable.getChildren().add(gridManager.getGridPane());
gridManager.renderLobbyButtons();
}
/** Handles the login button action. Validates input and calls LobbyClient.login(). */
@FXML
public void handleLoginButton() {
String username = usernameField.getText();
if (username == null || username.isBlank()) {
showAlert("Please enter a username.");
return;
}
// Only allow alphanumeric, _ and -
if (!username.matches("[a-zA-Z0-9_-]+")) {
showAlert("Only letters, numbers, '_' and '-' are allowed!");
return;
}
if (lobbyClient.getClientService().isOffline()) {
showAlert("Offline mode: cannot send login to server.");
return;
}
try {
lobbyClient.login(username);
showAlert("Login sent: " + username);
} catch (RuntimeException e) {
LOGGER.error("Login failed: {}", e.getMessage());
showAlert("Login failed: " + e.getMessage());
}
}
/** Shows an alert dialog with the given message. */
private void showAlert(String message) {
Alert alert = new Alert(AlertType.INFORMATION);
alert.setTitle("Info");
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
casinoTable.getChildren().clear();
casinoTable.getChildren().add(gridManager.getGridPane());
gridManager.renderLobbyButtons();
@@ -62,13 +125,21 @@ public class CasinomainuiController {
return;
}
int buttonId = nextButtonId++;
int lobbyId = gridManager.createLobby();
try {
String username = usernameField != null ? usernameField.getText() : "<unknown>";
LOGGER.info("Creating lobby for user: {}", username);
// avoid attempting to create a lobby when offline
if (lobbyClient.getClientService().isOffline()) {
LOGGER.warn("Cannot create lobby while offline");
showAlert("Offline mode: cannot create lobby.");
return;
}
int lobbyId = gridManager.createLobby();
translationManager.addLobbyButton(buttonId, lobbyId);
LOGGER.info("ButtonID: {}, LobbyID: {}", buttonId, lobbyId);
gridManager.renderLobbyButtons();
} catch (Exception e) {
LOGGER.error("Error while adding lobby button: {}", e.getMessage());
LOGGER.error("Failed to create or add lobby: {}", e.getMessage());
}
}
}
@@ -1,10 +1,18 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
/**
* Manages the grid for lobby buttons and rendering. Uses LobbyButtonTranslationManager for mapping
* ButtonID to LobbyID.
*/
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
@@ -12,133 +20,349 @@ import javafx.scene.layout.GridPane;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Manages the grid for lobby buttons and rendering. Uses LobbyButtonTranslationManager for mapping
* ButtonID to LobbyID.
*/
public class LobbyButtonGridManager {
private static final double BUTTON_WIDTH_MARGIN = 20.0;
private static final double BTN_WIDTH_MRG = 20.0;
private static final double BUTTON_MIN_SIZE = 10.0;
private static final Logger LOGGER = LogManager.getLogger(LobbyButtonGridManager.class);
/** GridPane for the button grid. */
private final GridPane gridPane;
/** Manager for mapping ButtonID to LobbyID. */
private final LobbyButtonTranslationManager translationManager;
/** Number of rows in the grid. */
private static final int ROWS = 2;
/** Number of columns in the grid. */
private static final int REFRESH_INTERVAL_SECONDS = 5;
private static final int INITIAL_DELAY_SECONDS = 5;
private static final int COLS = 4;
/** Path to the button image. */
private static final String BUTTON_IMAGE_PATH = "/images/logo.png";
private static final Logger LOGGER = LogManager.getLogger(LobbyButtonGridManager.class);
/** Max random lobby id. */
private static final int MAX_RANDOM_LOBBY_ID = 10000;
private static final String BUTTON_FALLBACK_IMAGE = "/images/lobbypictures/error.png";
private static final String BUTTON_IMAGE_TEMPLATE = "/images/lobbypictures/lobby_%d_%s.png";
private final GridPane gridPane;
private final LobbyButtonTranslationManager translationManager;
private final LobbyClient lobbyClient;
private final ConcurrentHashMap<String, Image> imageCache = new ConcurrentHashMap<>();
private final ExecutorService executor = Executors.newCachedThreadPool();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
/**
* Constructor for the GridManager.
*
* @param gridPane the GridPane for rendering
* @param translationManager the manager for mapping ButtonID to LobbyID
*/
public LobbyButtonGridManager(
GridPane gridPane, LobbyButtonTranslationManager translationManager) {
GridPane gridPane,
LobbyButtonTranslationManager translationManager,
LobbyClient lobbyClient) {
this.gridPane = gridPane;
// Singleton immer verwenden
this.translationManager = LobbyButtonTranslationManager.getInstance();
this.lobbyClient = lobbyClient;
startPeriodicRefresh(REFRESH_INTERVAL_SECONDS, INITIAL_DELAY_SECONDS);
}
/**
* Renders all lobby buttons in the grid. Creates a button for each mapping with image and event
* handler.
*/
public void renderLobbyButtons() {
gridPane.getChildren().clear();
int index = 0;
public LobbyButtonGridManager(
GridPane gridPane,
LobbyButtonTranslationManager translationManager,
ClientService clientService) {
this(gridPane, translationManager, new LobbyClient(clientService));
}
private void startPeriodicRefresh(long initialDelay, long period) {
scheduler.scheduleAtFixedRate(
this::refreshMappings, initialDelay, period, TimeUnit.SECONDS);
}
private void refreshMappings() {
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
if (mapping.isEmpty()) {
// No buttons to render
return;
}
for (Map.Entry<Integer, Integer> entry : mapping.entrySet()) {
int buttonId = entry.getKey();
Button btn = new Button();
btn.setId("lobbyBtn-" + buttonId);
ImageView imageView =
new ImageView(new Image(getClass().getResourceAsStream(BUTTON_IMAGE_PATH)));
imageView.setPreserveRatio(true);
// Dynamische Breite: Bindung an die Zellengröße
imageView
.fitWidthProperty()
.bind(gridPane.widthProperty().divide(COLS).subtract(BUTTON_WIDTH_MARGIN));
imageView.setSmooth(true);
btn.setGraphic(imageView);
btn.setMaxWidth(Double.MAX_VALUE);
btn.setMaxHeight(Double.MAX_VALUE);
btn.setMinWidth(BUTTON_MIN_SIZE);
btn.setMinHeight(BUTTON_MIN_SIZE);
GridPane.setHgrow(btn, javafx.scene.layout.Priority.ALWAYS);
GridPane.setVgrow(btn, javafx.scene.layout.Priority.ALWAYS);
btn.setOnAction(
e -> {
Integer lobbyId = translationManager.getLobbyIdForButton(buttonId);
if (lobbyId != null) {
joinLobby(lobbyId);
}
});
int row = index / COLS;
int col = index % COLS;
gridPane.add(btn, col, row);
index++;
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(mapping.entrySet());
for (Map.Entry<Integer, Integer> e : entries) {
int buttonId = e.getKey();
int lobbyId = e.getValue();
CompletableFuture.supplyAsync(
() -> {
try {
return lobbyClient.fetchLobbyStatusString(lobbyId);
} catch (Exception ex) {
LOGGER.info("Lobby {} missing: {}", lobbyId, ex.getMessage());
return null;
}
},
executor)
.thenAccept(
status -> {
if (status == null) {
translationManager.removeLobbyButton(buttonId);
javafx.application.Platform.runLater(
this::updateLobbyButtonImages);
}
});
}
}
/**
* Placeholder for lobby creation logic. Returns a generated lobbyId.
*
* @return The generated lobbyId
*/
public int createLobby() {
// TODO: Replace with actual lobby creation logic
int lobbyId = (int) (Math.random() * MAX_RANDOM_LOBBY_ID + 1);
LOGGER.info("Lobby created: {}", lobbyId);
return lobbyId;
public void renderLobbyButtons() {
gridPane.getChildren().clear();
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
if (mapping.isEmpty()) {
return;
}
List<Integer> buttonIds = new ArrayList<>(mapping.keySet());
Collections.sort(buttonIds);
for (int index = 0; index < buttonIds.size(); index++) {
Integer buttonId = buttonIds.get(index);
int lobbyId = mapping.get(buttonId);
Button btn = createLobbyButton(buttonId, lobbyId);
int row = index / COLS;
int col = index % COLS;
gridPane.add(btn, col, row);
}
}
private Button createLobbyButton(int buttonId, int lobbyId) {
Button btn = new Button();
btn.setId("lobbyBtn-" + buttonId);
ImageView imageView = new ImageView(safeLoadImage(BUTTON_FALLBACK_IMAGE));
imageView.setPreserveRatio(true);
imageView
.fitWidthProperty()
.bind(gridPane.widthProperty().divide(COLS).subtract(BTN_WIDTH_MRG));
btn.setGraphic(imageView);
btn.setMaxWidth(Double.MAX_VALUE);
btn.setMaxHeight(Double.MAX_VALUE);
btn.setMinWidth(BUTTON_MIN_SIZE);
btn.setMinHeight(BUTTON_MIN_SIZE);
GridPane.setHgrow(btn, javafx.scene.layout.Priority.ALWAYS);
GridPane.setVgrow(btn, javafx.scene.layout.Priority.ALWAYS);
btn.setOnAction(
e -> {
Integer targetLobbyId = translationManager.getLobbyIdForButton(buttonId);
if (targetLobbyId != null) {
joinLobby(targetLobbyId);
}
});
loadLobbyImageAsync(btn, buttonId, lobbyId);
return btn;
}
private void loadLobbyImageAsync(Button btn, int buttonId, int lobbyId) {
CompletableFuture.supplyAsync(() -> lobbyClient.fetchLobbyStatusString(lobbyId), executor)
.thenAccept(
statusStr -> {
LobbyStatus status = parseLobbyStatus(statusStr);
String path =
getImagePathForButton(
buttonId,
status == null ? LobbyStatus.CREATED : status);
Image img = safeLoadImage(path);
javafx.application.Platform.runLater(
() -> {
ImageView iv = new ImageView(img);
iv.setPreserveRatio(true);
iv.fitWidthProperty()
.bind(
gridPane.widthProperty()
.divide(COLS)
.subtract(BTN_WIDTH_MRG));
btn.setGraphic(iv);
});
});
}
private enum LobbyStatus {
CREATED,
RUNNING
}
private LobbyStatus parseLobbyStatus(String statusStr) {
if (statusStr == null) {
return null;
}
try {
return LobbyStatus.valueOf(statusStr.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return null;
}
}
private String getImagePathForButton(int buttonId, LobbyStatus status) {
String statusStr = status == LobbyStatus.CREATED ? "created" : "running";
return String.format(BUTTON_IMAGE_TEMPLATE, buttonId, statusStr);
}
private Image safeLoadImage(String path) {
Image cached = imageCache.get(path);
if (cached != null) {
return cached;
}
java.io.InputStream is = getClass().getResourceAsStream(path);
if (is == null) {
is = getClass().getResourceAsStream(BUTTON_FALLBACK_IMAGE);
}
Image loaded = null;
try {
if (is != null) {
loaded = new Image(is);
}
} catch (Exception e) {
LOGGER.error("Image load failed: {}", path, e);
}
if (loaded != null) {
imageCache.put(path, loaded);
}
return loaded;
}
public void updateLobbyButtonImages() {
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
if (mapping.isEmpty()) {
return;
}
for (Integer buttonId : mapping.keySet()) {
int lobbyId = mapping.get(buttonId);
CompletableFuture.supplyAsync(
() -> {
String statusStr = lobbyClient.fetchLobbyStatusString(lobbyId);
LobbyStatus status = parseLobbyStatus(statusStr);
return status == null ? LobbyStatus.CREATED : status;
},
executor)
.thenAccept(
status -> {
String path = getImagePathForButton(buttonId, status);
javafx.application.Platform.runLater(
() -> {
for (Node node : gridPane.getChildren()) {
boolean isButton = node instanceof Button;
boolean idMatches =
("lobbyBtn-" + buttonId)
.equals(node.getId());
if (isButton && idMatches) {
Button btn = (Button) node;
ImageView iv =
new ImageView(safeLoadImage(path));
iv.setPreserveRatio(true);
iv.fitWidthProperty()
.bind(
gridPane.widthProperty()
.divide(COLS)
.subtract(
BTN_WIDTH_MRG));
btn.setGraphic(iv);
break;
}
}
});
});
}
}
public int createLobby() {
try {
int lobbyId = lobbyClient.createLobby();
if (lobbyId <= 0) {
throw new RuntimeException("Invalid lobby id: " + lobbyId);
}
return lobbyId;
} catch (Exception e) {
LOGGER.error("Create lobby failed: {}", e.getMessage());
throw new RuntimeException(e);
}
}
/**
* Placeholder for joining a lobby.
*
* @param lobbyId The lobbyId to join
*/
public void joinLobby(int lobbyId) {
// Game-UI starten und Lobby-UI schließen
LOGGER.info("Joining lobby: {}", lobbyId);
try {
lobbyClient.joinLobby(lobbyId);
} catch (Exception e) {
LOGGER.error("Join failed: {}", e.getMessage());
return;
}
javafx.application.Platform.runLater(
() -> {
// Lobby-Stage schließen
javafx.stage.Stage currentStage =
(javafx.stage.Stage) gridPane.getScene().getWindow();
currentStage.close();
// Game-UI starten
currentStage.hide();
javafx.stage.Stage gameStage = new javafx.stage.Stage();
gameStage.setOnHidden(
ev -> {
currentStage.show();
refreshMappings();
updateLobbyButtonImages();
});
try {
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI
.setClientService(lobbyClient.getClientService());
new ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI()
.start(new javafx.stage.Stage());
.start(gameStage);
} catch (Exception e) {
LOGGER.error("Fehler beim Starten der Game-UI: {}", e.getMessage());
LOGGER.error("Game UI failed: {}", e.getMessage());
currentStage.show();
}
});
}
/**
* Getter for the GridPane.
*
* @return The GridPane for the button grid
*/
public javafx.scene.layout.GridPane getGridPane() {
public GridPane getGridPane() {
return gridPane;
}
public LobbyClient getLobbyClient() {
return lobbyClient;
}
public void refreshNow() {
refreshMappings();
}
}
@@ -3,6 +3,12 @@ package ch.unibas.dmi.dbis.cs108.casono.server;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersRequest;
@@ -15,6 +21,10 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.logout.LogoutRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingParser;
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;
@@ -76,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();
@@ -93,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));
@@ -110,8 +122,135 @@ public class ServerApp {
commandRouter.register(
LogoutRequest.class, new LogoutHandler(responseDispatcher, userRegistry));
parserDispatcher.register("SEND_MESSAGE", new SendMessageParser());
commandRouter.register(
SendMessageRequest.class, new SendMessageHandler(responseDispatcher, userRegistry));
parserDispatcher.register("GET_MESSAGE_COUNT", new GetMessageCountParser());
commandRouter.register(
GetMessageCountRequest.class,
new GetMessageCountHandler(responseDispatcher, userRegistry));
parserDispatcher.register("GET_NEXT_MESSAGE", new GetNextMessageParser());
commandRouter.register(
GetNextMessageRequest.class,
new GetNextMessageHandler(responseDispatcher, userRegistry));
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,49 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count;
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;
import java.util.Optional;
public class GetMessageCountHandler extends CommandHandler<GetMessageCountRequest> {
private final UserRegistry userRegistry;
/**
* Constructs a new GetMessageCountHandler with the necessary response dispatcher and user
* registry.
*
* @param responseDispatcher The dispatcher used to send the count or error back to the client.
* @param userRegistry The registry used to identify the user and access their message queue.
*/
public GetMessageCountHandler(
ResponseDispatcher responseDispatcher, UserRegistry userRegistry) {
super(responseDispatcher);
this.userRegistry = userRegistry;
}
/**
* Processes a request to retrieve the number of pending messages for a user. It looks up the
* user by their session ID; if found, it dispatches a {@link GetMessageCountResponse}
* containing the current count. Otherwise, it dispatches an {@link ErrorResponse}.
*
* @param request The {@link GetMessageCountRequest} containing the session details.
*/
@Override
public void execute(GetMessageCountRequest request) {
Optional<User> user = userRegistry.getBySessionId(request.getSessionId());
if (user.isPresent()) {
int count = user.get().getMessageCount();
GetMessageCountResponse response =
new GetMessageCountResponse(request.getContext(), count);
responseDispatcher.dispatch(response);
} else {
ErrorResponse response =
new ErrorResponse(
request.getContext(), "", "user could not be identified by SessionId");
responseDispatcher.dispatch(response);
}
}
}
@@ -0,0 +1,22 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count;
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 GetMessageCountParser implements CommandParser<GetMessageCountRequest> {
/**
* Parses a raw {@link PrimitiveRequest} into a {@link GetMessageCountRequest}. This method
* wraps the request context from the network layer into a structured message count request
* object.
*
* @param primitiveRequest The raw request containing parameters and context from the network.
* @return A new {@link GetMessageCountRequest} instance.
*/
@Override
public GetMessageCountRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
return new GetMessageCountRequest(primitiveRequest.context());
}
}
@@ -0,0 +1,17 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count;
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 GetMessageCountRequest extends Request {
/**
* Constructs a new GetMessageCountRequest with the specified request context. This request is
* used by a client to query the number of pending messages currently waiting in their
* server-side queue.
*
* @param context The {@link RequestContext} associated with this request.
*/
public GetMessageCountRequest(RequestContext context) {
super(context);
}
}
@@ -0,0 +1,18 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBodyBuilder;
public class GetMessageCountResponse extends SuccessResponse {
/**
* Constructs a new GetMessageCountResponse. It creates a response body containing the "COUNT"
* parameter and associates it with the original request context.
*
* @param context The {@link RequestContext} of the original request.
* @param count The number of pending messages to be returned to the client.
*/
public GetMessageCountResponse(RequestContext context, int count) {
super(context, new ResponseBodyBuilder().param("COUNT", count).build());
}
}
@@ -0,0 +1,49 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
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;
import java.util.Optional;
public class GetNextMessageHandler extends CommandHandler<GetNextMessageRequest> {
private final UserRegistry userRegistry;
/**
* Constructs a new GetNextMessageHandler with the required dispatcher and user registry.
*
* @param responseDispatcher The dispatcher used to send responses back to clients.
* @param userRegistry The registry used to look up users by their session information.
*/
public GetNextMessageHandler(ResponseDispatcher responseDispatcher, UserRegistry userRegistry) {
super(responseDispatcher);
this.userRegistry = userRegistry;
}
/**
* Executes the request to retrieve the next message for a specific user. It identifies the user
* via their session ID, dequeues the next available message, and dispatches a {@link
* GetNextMessageResponse}. If the user cannot be identified, an {@link ErrorResponse} is sent
* instead.
*
* @param request The {@link GetNextMessageRequest} containing the session and context.
*/
@Override
public void execute(GetNextMessageRequest request) {
Optional<User> user = userRegistry.getBySessionId(request.getSessionId());
if (user.isPresent()) {
Message msg = user.get().dequeMessage();
GetNextMessageResponse response = new GetNextMessageResponse(request.getContext(), msg);
responseDispatcher.dispatch(response);
} else {
ErrorResponse response =
new ErrorResponse(
request.getContext(),
"NO_USER_ASSOCIATED",
"user could not be identified by SessionId");
responseDispatcher.dispatch(response);
}
}
}
@@ -0,0 +1,22 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message;
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 GetNextMessageParser implements CommandParser<GetNextMessageRequest> {
/**
* Parses a raw {@link PrimitiveRequest} into a {@link GetNextMessageRequest}. This method
* initializes a parameter accessor (though not currently used for extraction) and returns a
* structured request object containing the original request context.
*
* @param primitiveRequest The raw request containing parameters and context from the network.
* @return A new {@link GetNextMessageRequest} instance.
*/
@Override
public GetNextMessageRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
return new GetNextMessageRequest(primitiveRequest.context());
}
}
@@ -0,0 +1,17 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message;
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 GetNextMessageRequest extends Request {
/**
* Constructs a new GetNextMessageRequest with the specified request context. This request is
* typically used by a client to poll or retrieve the next available message from the server's
* queue.
*
* @param context The {@link RequestContext} associated with this request.
*/
public GetNextMessageRequest(RequestContext context) {
super(context);
}
}
@@ -0,0 +1,19 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
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;
public class GetNextMessageResponse extends SuccessResponse {
/**
* Constructs a new GetNextMessageResponse. It converts the provided {@link Message} into a
* network-compatible response body and associates it with the original request context.
*
* @param context The {@link RequestContext} of the request being answered.
* @param msg The {@link Message} to be sent back to the client.
*/
public GetNextMessageResponse(RequestContext context, Message msg) {
super(context, msg.toResponse(ResponseBody.builder()));
}
}
@@ -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;
}
}
@@ -0,0 +1,47 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
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.OkResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
private final UserRegistry userRegistry;
/**
* Constructs a new SendMessageHandler with the required dispatcher and user registry.
*
* @param responseDispatcher The dispatcher used to send responses back to clients.
* @param userRegistry The registry containing all currently connected users.
*/
public SendMessageHandler(ResponseDispatcher responseDispatcher, UserRegistry userRegistry) {
super(responseDispatcher);
this.userRegistry = userRegistry;
}
/**
* Processes a message send request. This method extracts the message from the request,
* broadcasts it to all connected users, and dispatches a success response (OK) back to the
* sender.
*
* @param request The {@link SendMessageRequest} containing the message and context.
*/
@Override
public void execute(SendMessageRequest request) {
Message message = request.getMessage();
broadcast(message);
OkResponse response = new OkResponse(request.getContext());
responseDispatcher.dispatch(response);
}
/**
* Distributes a message to every user currently registered in the system. Each user's message
* queue is updated with the new message.
*
* @param message The {@link Message} object to be broadcast.
*/
public void broadcast(Message message) {
userRegistry.getAllUsers().forEach(user -> user.enqueueMessage(message));
}
}
@@ -0,0 +1,22 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
public class SendMessageParser implements CommandParser<SendMessageRequest> {
/**
* Parses a raw {@link PrimitiveRequest} into a specific {@link SendMessageRequest}. This method
* extracts the message details from the request parameters and wraps them along with the
* request context into a structured request object.
*
* @param primitiveRequest The raw request containing parameters and context from the network.
* @return A structured {@link SendMessageRequest} containing the parsed {@link Message}.
*/
@Override
public SendMessageRequest parse(PrimitiveRequest primitiveRequest) {
Message msg = Message.toMessageReqPars(primitiveRequest.parameters());
return new SendMessageRequest(primitiveRequest.context(), msg);
}
}
@@ -0,0 +1,30 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
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 SendMessageRequest extends Request {
private final Message msg;
/**
* Constructs a new SendMessageRequest with the given context and message.
*
* @param context The {@link RequestContext} associated with this request.
* @param msg The {@link Message} object to be processed.
*/
public SendMessageRequest(RequestContext context, Message msg) {
super(context);
this.msg = msg;
}
/**
* Returns the message contained within this request.
*
* @return The {@link Message} instance.
*/
public Message getMessage() {
return msg;
}
}
@@ -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);
}
}
}
@@ -1,15 +0,0 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.message;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
public class MessageManager {
private final UserRegistry userRegistry;
public MessageManager(UserRegistry userRegistry) {
this.userRegistry = userRegistry;
}
public void broadcast(Message message) {
userRegistry.getAllUsers().forEach(user -> user.enqueueMessage(message));
}
}
@@ -1,10 +1,11 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.message.Message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
@@ -98,6 +99,14 @@ public class User {
messages.add(message);
}
public synchronized int getMessageCount() {
return messages.size();
}
public synchronized Message dequeMessage() throws NoSuchElementException {
return messages.remove();
}
public synchronized List<Message> dequeueAllMessages(Message message) {
List<Message> allMessages = new ArrayDeque<>(messages).stream().toList();
messages.clear();