diff --git a/build.gradle b/build.gradle index 4360c82..6f82563 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ dependencies { // Source: https://mvnrepository.com/artifact/org.apache.logging.log4j implementation("org.apache.logging.log4j:log4j-api:2.25.3") runtimeOnly("org.apache.logging.log4j:log4j-core:2.25.3") - + implementation("org.jspecify:jspecify:1.0.0") // Source: https://mvnrepository.com/artifact/org.fusesource.jansi/jansi runtimeOnly("org.fusesource.jansi:jansi:2.4.2") diff --git a/documents/docs/networking/client-network-architecture.md b/documents/docs/networking/client-network-architecture.md new file mode 100644 index 0000000..38733fe --- /dev/null +++ b/documents/docs/networking/client-network-architecture.md @@ -0,0 +1,249 @@ +# Client Nework Architecture + + +* [Architecture Overview](#architecture-overview) + * [network/Card.java](#networkcardjava) + * [network/GameState.java](#networkgamestatejava) + * [network/Player.java](#networkplayerjava) + * [network/ChatClient.java](#networkchatclientjava) + * [ChatClient(ClientService clientService)](#chatclientclientservice-clientservice) + * [sendMessage(Message message)](#sendmessagemessage-message) + * [getMessages()](#getmessages) + * [network/ClientService.java](#networkclientservicejava) + * [ClientService(String ip, int port)](#clientservicestring-ip-int-port) + * [processCommand(String message)](#processcommandstring-message) + * [sendRequest(Runnable request)](#sendrequestrunnable-request) + * [getRuntimeException(Exception e)](#getruntimeexceptionexception-e) + * [closeSocket()](#closesocket) + * [writeToTransport(String s) throws IOException](#writetotransportstring-s-throws-ioexception) + * [network/CoreClient.java](#networkcoreclientjava) + * [CoreClient(ClientService clientservice)](#coreclientclientservice-clientservice) + * [ping()](#ping) + * [login(String user)](#loginstring-user) + * [network/GameClient.java](#networkgameclientjava) + * [GameClient(ClientService client)](#gameclientclientservice-client) + * [getGameState()](#getgamestate) + * [parseGameState(String input)](#parsegamestatestring-input) + * [Example server response](#example-server-response) + * [network/LobbyClient.java](#networklobbyclientjava) + * [LobbyClient(ClientService client)](#lobbyclientclientservice-client) + * [fetchLobbyStatusString(int lobbyId)](#fetchlobbystatusstringint-lobbyid) + * [createLobby()](#createlobby) + * [getLobbyId()](#getlobbyid) + * [joinLobby(int lobbyId)](#joinlobbyint-lobbyid) + + + +## Architecture Overview + +```text +client/ + ├── game/ + │ ├── Card.java + │ ├── GameState.java + │ └── Player.java + │ + └── network/ + ├── ChatClient.java + ├── ClientService.java + ├── CoreClient.java + ├── GameClient.java + └── LobbyClient.java +``` + +### game/Card.java + +Represents a playing card with a value and suit. + +### game/GameState.java + +Represents the current state of the poker game, including the phase, pot size, current bet, dealer position, active player, community cards, and player information. + +### game/Player.java + +Represents a player in the poker game, including their name, chip count, current bet, state (e.g., `active`, `folded`), and their hole cards. + +### network/ChatClient.java + +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. + +#### ChatClient(ClientService clientService) + +Constructs a ChatClient with the given ClientService for communication. + +- **Parameter (`clientService`)**: The ClientService instance used to send commands and receive responses from the server. + +#### sendMessage(Message message) + +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. + +- **Parameter (`message`)**: message The Message object to be sent to the server. + +#### getMessages() + +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. + +- **Parameter (`A`)**: list of Message objects representing the messages retrieved from the server. + +### network/ClientService.java + +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. + +#### ClientService(String ip, int port) + +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. + +- **Parameter (`ip`)**: The IP address of the server to connect to. +- **Parameter (`port`)**: The port number of the server to connect to. + +#### processCommand(String message) + +Sends a command to the server and waits for the response. The command is sent using the TcpTransport, and the response is read in a loop until a valid response is received. The method handles `+OK` and `-ERROR` responses from the server and returns the actual response content. + +- **Parameter (`message`)**: The command message to be sent to the server. +- **Return**: The response from the server as a string. + +#### sendRequest(Runnable request) + +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. + +- **Parameter (`request`)**: The Runnable task representing the request to be sent to the server. + +#### getRuntimeException(Exception 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. + +- **Parameter (`e`)**: The exception from which to extract the cause. +- **Return**: A RuntimeException representing the cause of the original exception. + +#### closeSocket() + +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. + +#### writeToTransport(String s) throws IOException + +Helper method to write a command string to the TcpTransport. It generates a unique ID for the command using the idGenerator and sends a RawPacket containing the ID and the command string to the server. If an IOException occurs during this process, it throws a RuntimeException with the cause. + +- **Parameter (`s`)**: The command string to be sent to the server. +- **Throws IOException**: If an I/O error occurs while writing to the transport. + +### network/CoreClient.java + +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. + +#### CoreClient(ClientService clientservice) + +Constructs a CoreClient with the given ClientService for communication. + +- **Parameter (`clientservice`)**: The ClientService instance used to send commands and receive responses from the server. + +#### ping() + +Sends a `PING` command to the server to check connectivity. The server should respond with a `PONG` message if the connection is successful. + +#### login(String user) + +Logs in to the server with the given username by sending a `LOGIN` command. + +- **Parameter (`user`)**: The username to log in with. + +### network/GameClient.java + +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. + +#### GameClient(ClientService client) + +Constructs a GameClient with the given ClientService for communication. + +- **Parameter (`client`)**: The ClientService instance used to send commands and receive responses from the server. + +#### getGameState() + +Retrieves the current game state from the server by sending a command and parsing the response. + +- **Return**: A GameState object representing the current state of the game. + +#### parseGameState(String input) + +Parses the raw response from the server into a structured GameState object. + +- **Parameter (`input`)**: The raw response string from the server. +- **Return**: A GameState object representing the current state of the game. + +#### Example server response + +```text ++OK + PHASE=FLO P + POT=150 + CURRENT_BET=50 + DEALER=0 + ACTIVE_PLAYER=1 + CARDS + CARD + VALUE=10 + SUIT=H + CARD + VALUE=7 + SUIT=S + CARD + VALUE=A + SUIT=D + PLAYERS + PLAYER + NAME=Max + CHIPS=1200 + BET=50 + STATE=ACTIVE + CARDS + CARD + VALUE=K + SUIT=H + CARD + VALUE=3 + SUIT=C + PLAYER + NAME=Anna + CHIPS=800 + BET=0 + STATE=FOLDED + CARDS +END +``` + +### network/LobbyClient.java + +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. + +#### LobbyClient(ClientService client) + +Constructs a LobbyClient with the given ClientService for communication. + +- **Parameter (`client`)**: The ClientService instance used to send commands and receive responses from the server. + +#### fetchLobbyStatusString(int lobbyId) + +Fetch the current status of the lobby with the given id from the server. + +- **Parameter (`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. + +#### createLobby() + +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. + +#### getLobbyId() + +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. + +#### joinLobby(int lobbyId) + +Request the server to join the lobby with the given id. + +- **Parameter (`lobbyId`)**: The id of the lobby to join. diff --git a/documents/docs/networking/commands/protocol-document.md b/documents/docs/networking/commands/protocol-document.md index b08cf7f..340dc8e 100644 --- a/documents/docs/networking/commands/protocol-document.md +++ b/documents/docs/networking/commands/protocol-document.md @@ -341,4 +341,116 @@ LIST_USERS END END END +``` + +## SEND_MESSAGE command +The `SEND_MESSAGE` command is used to transfer the chat message sent by a user to the server. +### Required pre-execution checks +None. + +### Request Parameters + +| Field | Type | Description | +|:---------|:----------------|:-------------------------------------------------------------------| +| `TYPE` | `Enum getChatModelMap() { + return chatModelMap; + } + + private Map 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 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); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatModel.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatModel.java new file mode 100644 index 0000000..69cdecb --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatModel.java @@ -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 + * + *

Holds the current state of a chat + */ +public class ChatModel { + + private ArrayList> listeners = new ArrayList<>(); + + public ArrayList 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(); + 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 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; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatType.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatType.java new file mode 100644 index 0000000..b6a7167 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatType.java @@ -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 +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/Message.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/Message.java new file mode 100644 index 0000000..abe2ec1 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/Message.java @@ -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 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 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 parameters, String keyString, String defaultVal) { + Optional 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(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/Card.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/Card.java new file mode 100644 index 0000000..5007e2a --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/Card.java @@ -0,0 +1,7 @@ +package ch.unibas.dmi.dbis.cs108.casono.client.game; + +/** Represents a playing card with a value and suit. */ +public class Card { + public String value; + public String suit; +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/GameState.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/GameState.java new file mode 100644 index 0000000..4423573 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/GameState.java @@ -0,0 +1,19 @@ +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 List communityCards = new ArrayList<>(); + public List players = new ArrayList<>(); +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/Player.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/Player.java new file mode 100644 index 0000000..06436fb --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/game/Player.java @@ -0,0 +1,17 @@ +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 { + public String name; + public int chips; + public int bet; + public String state; + + public List cards = new ArrayList<>(); +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ChatClient.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ChatClient.java new file mode 100644 index 0000000..f47ea87 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ChatClient.java @@ -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 getMessages() { + logger.info("Asking server for new messages"); + List 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 messages = new ArrayList<>(); + for (int i = 0; i < count; i++) { + List msgRes = + ClientService.convertToRequestParameters( + clientService.processCommand("GET_NEXT_MESSAGE")); + Message msg = Message.toMessageReqPars(msgRes); + messages.add(msg); + } + return messages; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java new file mode 100644 index 0000000..ccf284e --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/ClientService.java @@ -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 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( + "(?\\w+)=(('(?([^']|\\')+)')|(?[+-]?[\\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 convertToRequestParameters(List 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 processCommand(String message) { + List 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"); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/CoreClient.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/CoreClient.java new file mode 100644 index 0000000..2b3b806 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/CoreClient.java @@ -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); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/GameClient.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/GameClient.java new file mode 100644 index 0000000..71b1d7b --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/GameClient.java @@ -0,0 +1,99 @@ +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 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; + + /** + * 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) { + this.client = client; + } + + /** + * Retrieves the current game state from the server by sending a command and parsing the + * response. + * + * @return A GameState object representing the current state of the game. + */ + public GameState getGameState() { + List response = client.processCommand("GET_GAME_STATE"); + return parseGameState(response); + } + + /** + * Parses the raw response from the server into a structured GameState object. + * + * @param input The raw response string from the server. + * @return A GameState object representing the current state of the game. + */ + private GameState parseGameState(List input) { + + GameState state = new GameState(); + + // String[] lines = input.split("\n"); + + Player currentPlayer = null; + Card currentCard = null; + + for (String rawLine : input) { + + String line = rawLine.trim(); + + if (line.startsWith("+OK") || line.equals("END")) { + continue; + } + + if (line.startsWith("PHASE=")) { + state.phase = line.split("=")[1]; + } else if (line.startsWith("POT=")) { + state.pot = Integer.parseInt(line.split("=")[1]); + } else if (line.startsWith("CURRENT_BET=")) { + state.currentBet = Integer.parseInt(line.split("=")[1]); + } else if (line.startsWith("DEALER=")) { + state.dealer = Integer.parseInt(line.split("=")[1]); + } else if (line.startsWith("ACTIVE_PLAYER=")) { + state.activePlayer = Integer.parseInt(line.split("=")[1]); + } else if (line.startsWith("PLAYER")) { + currentPlayer = new Player(); + state.players.add(currentPlayer); + } else if (line.startsWith("NAME=") && currentPlayer != null) { + currentPlayer.name = line.split("=")[1]; + } else if (line.startsWith("CHIPS=") && currentPlayer != null) { + currentPlayer.chips = Integer.parseInt(line.split("=")[1]); + } else if (line.startsWith("BET=") && currentPlayer != null) { + currentPlayer.bet = Integer.parseInt(line.split("=")[1]); + } else if (line.startsWith("STATE=") && currentPlayer != null) { + currentPlayer.state = line.split("=")[1]; + } else if (line.startsWith("CARD")) { + currentCard = new Card(); + + if (currentPlayer != null) { + currentPlayer.cards.add(currentCard); + } else { + state.communityCards.add(currentCard); + } + } else if (line.startsWith("VALUE=") && currentCard != null) { + currentCard.value = line.split("=")[1]; + } else if (line.startsWith("SUIT=") && currentCard != null) { + currentCard.suit = line.split("=")[1]; + } + } + + return state; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java new file mode 100644 index 0000000..853ed56 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/network/LobbyClient.java @@ -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); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java new file mode 100644 index 0000000..618d309 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatBoxController.java @@ -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 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); + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatController.java deleted file mode 100644 index 1f7abe3..0000000 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatController.java +++ /dev/null @@ -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. - * - *

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) - * - *

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. - * - *

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); - } -} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java new file mode 100644 index 0000000..397e8f3 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/chatui/ChatViewController.java @@ -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); + }); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameUI.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameUI.java index 2e91921..b61a6e5 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameUI.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameUI.java @@ -1,23 +1,26 @@ 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; import javafx.scene.Scene; import javafx.stage.Stage; -/** - * Main class for the Casono Game UI. - * - *

Starts the JavaFX application, loads the graphical user interface from the FXML file, and - * initializes the main stage for the game. - * - *

Tasks: - Loads the FXML interface "/ui-structure/Casinogameui.fxml". - Loads the application - * icon from "/images/logoinverted.png". - Starts the application in full-screen mode. - */ public class CasinoGameUI extends Application { - /** default constructor */ + // Static field for ClientService (workaround for JavaFX Application launch) + private static ClientService staticClientService; + + public static void setClientService(ClientService clientService) { + staticClientService = clientService; + } + + public static ClientService getClientService() { + return staticClientService; + } + + /** Default no-arg constructor. */ public CasinoGameUI() { // default no-arg constructor } @@ -46,7 +49,7 @@ public class CasinoGameUI extends Application { } /** - * Starting point of the application. + * Entry point of the application. * * @param args Command line arguments. */ diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java index 0088781..bb7976c 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/Casinomainui.java @@ -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); diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java index c82a381..d18d36f 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/CasinomainuiController.java @@ -1,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() : ""; + 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()); } } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java index 3f75b0f..55bbdca 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/lobbyui/LobbyButtonGridManager.java @@ -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 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 mapping = translationManager.getButtonIdToLobbyId(); + if (mapping.isEmpty()) { - // No buttons to render return; } - for (Map.Entry 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> entries = new ArrayList<>(mapping.entrySet()); + + for (Map.Entry 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 mapping = translationManager.getButtonIdToLobbyId(); + + if (mapping.isEmpty()) { + return; + } + + List 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 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(); + } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index 57729a8..7062252 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -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,9 @@ 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.user.UserCleanupJob; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager; @@ -110,6 +119,20 @@ 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)); diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountHandler.java new file mode 100644 index 0000000..dcfe216 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountHandler.java @@ -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 { + 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 = 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); + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountParser.java new file mode 100644 index 0000000..1060200 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountParser.java @@ -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 { + /** + * 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()); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountRequest.java new file mode 100644 index 0000000..a1d0f7d --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountRequest.java @@ -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); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountResponse.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountResponse.java new file mode 100644 index 0000000..a58edd6 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_message_count/GetMessageCountResponse.java @@ -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()); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageHandler.java new file mode 100644 index 0000000..deb5773 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageHandler.java @@ -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 { + 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 = 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); + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageParser.java new file mode 100644 index 0000000..185c5b5 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageParser.java @@ -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 { + /** + * 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()); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageRequest.java new file mode 100644 index 0000000..76f6678 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageRequest.java @@ -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); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageResponse.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageResponse.java new file mode 100644 index 0000000..1fd9378 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/get_next_message/GetNextMessageResponse.java @@ -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())); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageHandler.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageHandler.java new file mode 100644 index 0000000..0bf5b95 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageHandler.java @@ -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 { + 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)); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageParser.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageParser.java new file mode 100644 index 0000000..eb8cf95 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageParser.java @@ -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 { + + /** + * 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); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageRequest.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageRequest.java new file mode 100644 index 0000000..839d32b --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/send_message/SendMessageRequest.java @@ -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; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/message/MessageManager.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/message/MessageManager.java deleted file mode 100644 index b3ba0da..0000000 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/message/MessageManager.java +++ /dev/null @@ -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)); - } -} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java index dc42ac3..f660c40 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java @@ -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; @@ -88,6 +89,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 dequeueAllMessages(Message message) { List allMessages = new ArrayDeque<>(messages).stream().toList(); messages.clear(); diff --git a/src/main/resources/ui-structure/Casinogameui.fxml b/src/main/resources/ui-structure/Casinogameui.fxml index 0a9e17d..db6ecca 100644 --- a/src/main/resources/ui-structure/Casinogameui.fxml +++ b/src/main/resources/ui-structure/Casinogameui.fxml @@ -5,37 +5,37 @@ - + + stylesheets="@casinogameui.css"> - + - + - + - + - + - + - + - + @@ -52,7 +52,7 @@ - + - + - + - + - + - + - + - + @@ -99,11 +99,11 @@ - + - + @@ -114,11 +114,11 @@ - + - + @@ -129,7 +129,7 @@ - + diff --git a/src/main/resources/ui-structure/Casinomainui.fxml b/src/main/resources/ui-structure/Casinomainui.fxml index 7cae21e..214c2a8 100644 --- a/src/main/resources/ui-structure/Casinomainui.fxml +++ b/src/main/resources/ui-structure/Casinomainui.fxml @@ -81,6 +81,23 @@ + + + + + + + +