diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatController.java index b07c712..09d7838 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/chat/ChatController.java @@ -4,8 +4,10 @@ import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp; import ch.unibas.dmi.dbis.cs108.casono.client.network.ChatClient; import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; import ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatBoxController; +import ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatViewController; import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -39,7 +41,11 @@ public class ChatController { return chatBoxController; } - private final ChatBoxController chatBoxController; + public void setChatBoxController(ChatBoxController chatBoxController) { + this.chatBoxController = chatBoxController; + } + + private ChatBoxController chatBoxController; private int lobbyId = -1; private final Timer timer; private final Consumer> serverEventListener; @@ -61,6 +67,12 @@ public class ChatController { /** List of all users connected to the server, to safe them locally on the client */ private final List localUserList; + public List getLocalUserList() { + return this.localUserList; + } + + public final Map activeChatControllers; + private final Logger logger; /** @@ -78,6 +90,7 @@ public class ChatController { this.chatBoxController = new ChatBoxController(username, this); this.logger = LogManager.getLogger(ChatController.class); this.serverEventListener = this::handleServerEvent; + this.activeChatControllers = new HashMap<>(); registerAsActiveController(clientService); clientService.addEventListener(serverEventListener); @@ -126,7 +139,7 @@ public class ChatController { } ChatModel lobbyChatModel = new ChatModel(ChatType.LOBBY, username, lobbyId, null); chatModelMap.put(key, lobbyChatModel); - this.chatBoxController.addChatTab("Lobby", lobbyChatModel); + this.chatBoxController.addChatTab("LOBBY", lobbyChatModel, ChatType.LOBBY); } /** 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 index 1864601..26aa545 100644 --- 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 @@ -3,6 +3,7 @@ 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 ch.unibas.dmi.dbis.cs108.casono.client.chat.Message; import java.io.IOException; import java.net.URL; import java.util.ArrayList; @@ -27,7 +28,7 @@ public class ChatBoxController { private String username; - private ChatController chatController; + private final ChatController chatController; @FXML private VBox chatBox; @@ -43,8 +44,6 @@ public class ChatBoxController { @FXML private final Map usernameTabMap; - private String ressource = "/ui-structure/components/chatui/chattab.fxml"; - /** * Constructor for the ChatBoxController, initializes the necessary fields and data structures * for managing chat tabs and whisper chats. @@ -73,11 +72,10 @@ public class ChatBoxController { */ @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); + ChatType global = ChatType.GLOBAL; + ChatModel globalChatModel = new ChatModel(global, username, -1, null); + chatController.getChatModelMap().put(new ChatController.ChatKey(global), globalChatModel); + addChatTab("GLOBAL", globalChatModel, global); addWhisperChatButton.setOnAction(event -> addWhisperChatButton.show()); } @@ -166,10 +164,11 @@ public class ChatBoxController { public void addWhisperChat(String target, ChatModel chatModel) { if (!activeWhisperChats.contains(target)) { activeWhisperChats.add(target); + ChatType whisper = ChatType.WHISPER; chatController .getChatModelMap() - .put(new ChatController.ChatKey(ChatType.WHISPER, target), chatModel); - addChatTab(target, chatModel); + .put(new ChatController.ChatKey(whisper, target), chatModel); + addChatTab(target, chatModel, whisper); } else { chatTabPane.getSelectionModel().select(usernameTabMap.get(target)); } @@ -184,7 +183,8 @@ public class ChatBoxController { * @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) { + public void addChatTab(String title, ChatModel chatModel, ChatType chatType) { + String ressource = "/ui-structure/components/chatui/chattab.fxml"; URL resource = getClass().getResource(ressource); FXMLLoader fxmlLoader = new FXMLLoader(resource); runOnPlatformSynchronized( @@ -193,6 +193,8 @@ public class ChatBoxController { ChatViewController chatViewController = new ChatViewController( this.chatController, chatModel, this.username); + chatController.activeChatControllers.put( + new ChatController.ChatKey(chatType), chatViewController); fxmlLoader.setController(chatViewController); Node load = fxmlLoader.load(); VBox.setVgrow(load, Priority.ALWAYS); @@ -239,4 +241,30 @@ public class ChatBoxController { } } } + + public void loadChats() { + Map chatModelMap = chatController.getChatModelMap(); + ChatController.ChatKey key = new ChatController.ChatKey(ChatType.GLOBAL); + ChatModel global = chatModelMap.get(key); + ChatViewController globalController = chatController.activeChatControllers.get(key); + + for (String user : chatController.getLocalUserList()) { + addWhisperUser(user); + } + + for (Message msg : global.messages) { + globalController.showMessage(msg); + } + for (String user : chatController.getLocalUserList()) { + ChatController.ChatKey whisperUserKey = + new ChatController.ChatKey(ChatType.WHISPER, user); + if (chatModelMap.containsKey(whisperUserKey)) { + ChatModel whisperChatModel = chatModelMap.get(whisperUserKey); + addWhisperChat(user, whisperChatModel); + for (Message msg : whisperChatModel.messages) { + chatController.activeChatControllers.get(whisperUserKey).showMessage(msg); + } + } + } + } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java index 1a65be2..f3ab44d 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/CasinoGameController.java @@ -18,15 +18,13 @@ import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; -import javafx.scene.Scene; +import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; -import javafx.stage.Stage; /** * Controller for the casino gaming area. @@ -67,7 +65,9 @@ public class CasinoGameController { private java.util.List lastCommunityKeys = java.util.List.of(); private java.util.List lastMyCardKeys = java.util.List.of(); private int lastPot = Integer.MIN_VALUE; + private ChatController chatController; + public static final double CHAT_CONTAINER_CONSTANT = 6.0; private static final int TOTAL_SLOTS = 5; private static final int PLAYER_SLOTS = 2; private int pot = 0; @@ -139,7 +139,6 @@ public class CasinoGameController { private static final double CHAT_WIDTH = 400; private static final double CHAT_HEIGHT = 600; - private ChatController chatController; private String chatUsername; private ClientService chatClientService; private int chatLobbyId = -1; @@ -197,6 +196,11 @@ public class CasinoGameController { } } + public void startChat(ChatController chatController) { + this.chatController = chatController; + initializeChatIfPossible(); + } + /** Set the PlayerId of the current player. */ @FXML public void initialize() { @@ -239,7 +243,8 @@ public class CasinoGameController { // empty display only (optional) renderCommunityCards(List.of()); renderPlayerCards(List.of()); - initializeChatIfPossible(); + + chatContainer.toFront(); } /** @@ -257,7 +262,6 @@ public class CasinoGameController { this.chatUsername = username; this.chatClientService = clientService; this.chatLobbyId = lobbyId; - initializeChatIfPossible(); } /** @@ -265,42 +269,32 @@ public class CasinoGameController { * is available and the chat has not already been initialized. */ private void initializeChatIfPossible() { - if (chatInitialized || chatClientService == null || chatUsername == null) { + if (chatInitialized + || chatClientService == null + || chatUsername == null + || chatController == null) { return; } try { - chatController = new ChatController(chatUsername, chatClientService); + chatController.updateUsername(chatUsername); URL resource = getClass().getResource("/ui-structure/components/chatui/chatbox.fxml"); FXMLLoader loader = new FXMLLoader(resource); loader.setController(chatController.getChatBoxController()); + Node chatNode = loader.load(); + chatContainer.getChildren().setAll(chatNode); + AnchorPane.setTopAnchor(chatNode, CHAT_CONTAINER_CONSTANT); + AnchorPane.setBottomAnchor(chatNode, CHAT_CONTAINER_CONSTANT); + AnchorPane.setLeftAnchor(chatNode, 0.0); + AnchorPane.setRightAnchor(chatNode, CHAT_CONTAINER_CONSTANT); - Parent root = loader.load(); - - Stage chatStage = new Stage(); - - chatStage.setTitle("Casono"); - - String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm(); - chatStage.getIcons().add(new Image(iconPath)); - - Scene scene = new Scene(root); - chatStage.setScene(scene); - - chatStage.setWidth(CHAT_WIDTH); - chatStage.setHeight(CHAT_HEIGHT); - - chatStage.setOnCloseRequest(event -> chatInitialized = false); - - chatStage.show(); + chatController.getChatBoxController().loadChats(); + chatInitialized = true; if (chatLobbyId >= 0) { chatController.setLobbyChat(chatLobbyId); } - - chatInitialized = true; - } catch (IOException e) { LOGGER.warning("Could not initialize game chat UI: " + e.getMessage()); } 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 d6908c1..c82dfe1 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,6 +1,7 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui; import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp; +import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController; import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService; import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId; import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; @@ -25,6 +26,7 @@ public class CasinoGameUI extends Application { private static final Logger LOG = Logger.getLogger(CasinoGameUI.class.getName()); private static ClientService clientService; + private static ChatController chatController; private static String username; private static int lobbyId = -1; @@ -56,6 +58,10 @@ public class CasinoGameUI extends Application { CasinoGameUI.username = username; } + public static void setChatController(ChatController chatController) { + CasinoGameUI.chatController = chatController; + } + /** * Sets the lobby ID to be used by the application. * @@ -124,6 +130,8 @@ public class CasinoGameUI extends Application { controller.setMyPlayerId(PlayerId.of(effectiveUsername)); controller.setChatContext(effectiveUsername, clientService, lobbyId); + controller.startChat(chatController); + Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT); stage.setTitle("Casono"); 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 b3c2297..518b8cf 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 @@ -141,6 +141,7 @@ public class CasinomainuiController { AnchorPane.setBottomAnchor(chatNode, 0.0); AnchorPane.setLeftAnchor(chatNode, 0.0); AnchorPane.setRightAnchor(chatNode, 0.0); + gridManager.setChatController(chatController); } catch (IOException e) { LOGGER.warn("Could not initialize lobby chat UI: {}", 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 e3f1d01..c2c5163 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,6 +1,7 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui; import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp; +import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController; import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI; @@ -43,6 +44,8 @@ public class LobbyButtonGridManager { private final LobbyButtonTranslationManager translationManager; private final LobbyClient lobbyClient; + private ChatController chatController; + private final ConcurrentHashMap imageCache = new ConcurrentHashMap<>(); private final ExecutorService executor = Executors.newCachedThreadPool(); @@ -557,6 +560,7 @@ public class LobbyButtonGridManager { CasinoGameUI.setClientService(cs); CasinoGameUI.setLobbyId(lobbyId); + CasinoGameUI.setChatController(chatController); String username = ClientApp.getSharedUsername(); if (username == null || username.isBlank()) { @@ -586,4 +590,8 @@ public class LobbyButtonGridManager { public LobbyClient getLobbyClient() { return lobbyClient; } + + public void setChatController(ChatController chatController) { + this.chatController = chatController; + } } 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 f93ef7c..a9a46c7 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 @@ -63,6 +63,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingRequest; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageHandler; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageParser; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyCleanupJob; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; @@ -73,11 +74,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandR import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher; import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent; import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus; -import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; -import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; -import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; -import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionDisconnectJob; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; import java.time.Duration; @@ -141,37 +138,12 @@ public class ServerApp { userRegistry, new CommandContext(lobbyManager, sessionManager)); - // Periodic cleanup: remove empty lobbies older than 30s and notify affected - // users scheduler.scheduleAtFixedRate( - () -> { - try { - var expired = - lobbyManager.findEmptyLobbiesOlderThan( - Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)); - for (var lid : expired) { - // remove lobby from manager first - lobbyManager.removeLobby(lid); - - // broadcast LOBBY_CLOSED event to all connected sessions - // (requestId=0) - for (Session s : sessionManager.getAllSessions()) { - RequestContext ctx = new RequestContext(s.getId(), 0); - SuccessResponse ev = - new SuccessResponse( - ctx, - ResponseBody.builder() - .param("EVENT", "LOBBY_CLOSED") - .param("LOBBY_ID", lid.value()) - .build()) {}; - - responseDispatcher.dispatch(ev); - } - } - } catch (Exception e) { - logger.warn("Lobby expiry job failed", e); - } - }, + new LobbyCleanupJob( + lobbyManager, + sessionManager, + responseDispatcher, + Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)), LOBBY_CLEANUP_INITIAL_DELAY_SECONDS, LOBBY_CLEANUP_PERIOD_SECONDS, TimeUnit.SECONDS); diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java new file mode 100644 index 0000000..47c0413 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java @@ -0,0 +1,100 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; +import java.time.Duration; +import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** Periodically removes expired empty lobbies and notifies connected sessions. */ +public class LobbyCleanupJob implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(LobbyCleanupJob.class); + + private final LobbyManager lobbyManager; + private final SessionManager sessionManager; + private final ResponseDispatcher responseDispatcher; + private final Duration expiryThreshold; + + /** + * Creates a new cleanup job for expired empty lobbies. + * + * @param lobbyManager manager used to find and remove expired lobbies + * @param sessionManager manager used to resolve connected sessions + * @param responseDispatcher dispatcher used to notify clients about closed lobbies + * @param expiryThreshold age threshold that marks an empty lobby as expired + */ + public LobbyCleanupJob( + LobbyManager lobbyManager, + SessionManager sessionManager, + ResponseDispatcher responseDispatcher, + Duration expiryThreshold) { + this.lobbyManager = lobbyManager; + this.sessionManager = sessionManager; + this.responseDispatcher = responseDispatcher; + this.expiryThreshold = expiryThreshold; + } + + /** + * Runs one cleanup cycle. + * + *

The method first fetches all expired empty lobbies. Each lobby is then processed + * independently so that a failure for one lobby does not stop the remaining cleanups. + */ + @Override + public void run() { + LOGGER.debug("Job started."); + try { + List expired; + try { + expired = lobbyManager.findEmptyLobbiesOlderThan(expiryThreshold); + } catch (Exception e) { + LOGGER.error("Lobby expiry job failed: could not fetch expired lobbies", e); + return; + } + + for (LobbyId lobbyId : expired) { + try { + lobbyManager.removeLobby(lobbyId); + broadcastLobbyClosed(lobbyId); + } catch (RuntimeException e) { + LOGGER.warn("Failed to process expired lobby {}", lobbyId.value(), e); + } + } + } finally { + LOGGER.debug("Job finished."); + } + } + + /** + * Broadcasts a lobby-closed event to all currently connected sessions. + * + * @param lobbyId id of the lobby that was closed + */ + private void broadcastLobbyClosed(LobbyId lobbyId) { + for (Session session : sessionManager.getAllSessions()) { + try { + RequestContext ctx = new RequestContext(session.getId(), 0); + SuccessResponse event = + new SuccessResponse( + ctx, + ResponseBody.builder() + .param("EVENT", "LOBBY_CLOSED") + .param("LOBBY_ID", lobbyId.value()) + .build()) {}; + responseDispatcher.dispatch(event); + } catch (RuntimeException e) { + LOGGER.warn( + "Failed to notify session {} about closed lobby {}", + session.getId().value(), + lobbyId.value(), + e); + } + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java index 330dcbb..2545fc3 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java @@ -48,7 +48,7 @@ public class TokenClassifier { } private static void validateSeparator(List rawTokens, int index) { - boolean missingKey = index == 0 || rawTokens.get(index - 1).type() != RawTokenType.WORD; + boolean missingKey = index <= 1 || rawTokens.get(index - 1).type() != RawTokenType.WORD; boolean missingValue = index + 1 >= rawTokens.size() diff --git a/src/main/resources/ui-structure/Casinogameui.fxml b/src/main/resources/ui-structure/Casinogameui.fxml index a5d5514..6e4369f 100644 --- a/src/main/resources/ui-structure/Casinogameui.fxml +++ b/src/main/resources/ui-structure/Casinogameui.fxml @@ -76,12 +76,14 @@ @@ -99,6 +101,7 @@ styleClass="dealer-box" fitWidth="100" fitHeight="100" + GridPane.columnIndex="2" preserveRatio="true" visible="false"/> @@ -117,56 +120,48 @@ + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java new file mode 100644 index 0000000..8c8748f --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java @@ -0,0 +1,94 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class TokenClassifierTest { + @Test + void testClassifySimpleCommand() { + List raw = Tokenizer.tokenize("PING"); + List tokens = TokenClassifier.classify(raw); + + assertEquals(2, tokens.size()); + assertEquals(TokenType.COMMAND, tokens.get(0).type()); + assertEquals("PING", tokens.get(0).value()); + assertEquals(TokenType.EOF, tokens.get(1).type()); + } + + @Test + void testCommandWithParameter() { + List raw = Tokenizer.tokenize("ANSWER VALUE=42"); + List tokens = TokenClassifier.classify(raw); + + assertEquals(5, tokens.size()); + assertEquals(TokenType.COMMAND, tokens.get(0).type()); + assertEquals(TokenType.KEY, tokens.get(1).type()); + assertEquals(TokenType.SEPARATOR, tokens.get(2).type()); + assertEquals(TokenType.VALUE, tokens.get(3).type()); + assertEquals("42", tokens.get(3).value()); + assertEquals(TokenType.EOF, tokens.get(4).type()); + } + + @Test + void testStringValue() { + List raw = Tokenizer.tokenize("GREET MSG='Hello World'"); + List tokens = TokenClassifier.classify(raw); + + assertEquals(5, tokens.size()); + assertEquals(TokenType.COMMAND, tokens.get(0).type()); + assertEquals(TokenType.KEY, tokens.get(1).type()); + assertEquals(TokenType.SEPARATOR, tokens.get(2).type()); + assertEquals(TokenType.VALUE, tokens.get(3).type()); + assertEquals("Hello World", tokens.get(3).value()); + } + + @Test + void testUnexpectedStringLiteralThrows() { + List raw = Tokenizer.tokenize("CMD 'oops'"); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertTrue(ex.getMessage().contains("Unexpected string literal")); + } + + @Test + void testMissingValueAfterSeparatorThrows() { + List raw = Tokenizer.tokenize("CMD KEY="); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected VALUE after '='", ex.getMessage()); + } + + @Test + void testMissingKeyBeforeSeparatorThrows() { + List raw = Tokenizer.tokenize("CMD =VALUE"); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected KEY before '='", ex.getMessage()); + } + + @Test + void testNextWordIsKeyThrows() { + List raw = Tokenizer.tokenize("CMD KEY1=KEY2=42"); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected VALUE after '='", ex.getMessage()); + } + + @Test + void testEmptyRawTokensThrows() { + List raw = new ArrayList<>(); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected COMMAND as first token", ex.getMessage()); + assertEquals(1, ex.getLine()); + assertEquals(1, ex.getColumn()); + } +} diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java new file mode 100644 index 0000000..bd5e486 --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java @@ -0,0 +1,211 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class TokenizerTest { + @Test + void testEofAlwaysPresent() { + List tokens = Tokenizer.tokenize(""); + + assertEquals(1, tokens.size()); + assertEquals(RawTokenType.EOF, tokens.get(0).type()); + } + + @Test + void testSimpleCommand() { + List tokens = Tokenizer.tokenize("PING"); + assertEquals(2, tokens.size()); + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("PING", tokens.get(0).value()); + assertEquals(RawTokenType.EOF, tokens.get(1).type()); + } + + @Test + void testSimpleCommandWithUnderscore() { + List tokens = Tokenizer.tokenize("JOIN_GAME"); + assertEquals(2, tokens.size()); + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("JOIN_GAME", tokens.get(0).value()); + assertEquals(RawTokenType.EOF, tokens.get(1).type()); + } + + @Test + void testCommandWithOneParameter() { + List tokens = Tokenizer.tokenize("GET VALUE=42"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GET", tokens.get(0).value()); + + // Parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("VALUE", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.WORD, tokens.get(3).type()); + assertEquals("42", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testCommandWithOneParameterWhitespacesBetweenSeperator() { + List tokens = Tokenizer.tokenize("GET VALUE = 42"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GET", tokens.get(0).value()); + + // Parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("VALUE", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.WORD, tokens.get(3).type()); + assertEquals("42", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testCommandWithOneParameterAndNewline() { + List tokens = Tokenizer.tokenize("GET\nVALUE=42"); + assertEquals(6, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GET", tokens.get(0).value()); + + // Parameter + assertEquals(RawTokenType.WORD, tokens.get(2).type()); + assertEquals("VALUE", tokens.get(2).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(3).type()); + assertEquals("=", tokens.get(3).value()); + + assertEquals(RawTokenType.WORD, tokens.get(4).type()); + assertEquals("42", tokens.get(4).value()); + + assertEquals(RawTokenType.EOF, tokens.get(5).type()); + } + + @Test + void testCommandWithStringParameter() { + List tokens = Tokenizer.tokenize("GREET MSG='Hello World'"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GREET", tokens.get(0).value()); + + // First parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("MSG", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("Hello World", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testCommandWithMultilineStringParameter() { + List tokens = Tokenizer.tokenize("GREET MSG='Hello\nWorld'"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GREET", tokens.get(0).value()); + + // First parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("MSG", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("Hello\nWorld", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testWhitespace() { + List tokens = Tokenizer.tokenize(" \t PING"); + assertEquals(2, tokens.size()); + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("PING", tokens.get(0).value()); + } + + @Test + void testStringWithEscapedQuote() { + List tokens = + Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\\'s a wonderful day'"); + assertEquals(5, tokens.size()); + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("it's a wonderful day", tokens.get(3).value()); + } + + @Test + void testStringWithWronglyEscapedQuote() { + TokenizerException ex = + assertThrows( + TokenizerException.class, + () -> Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\'s a wonderful day'")); + assertEquals("Unterminated string literal", ex.getMessage()); + } + + @Test + void testStringWithEscapedBackslash() { + List tokens = Tokenizer.tokenize("EXECUTE_COMMAND CMD='\\whoami'"); + assertEquals(5, tokens.size()); + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("\\whoami", tokens.get(3).value()); + } + + @Test + void testColumnTracking() { + List tokens = Tokenizer.tokenize("GET VAL=1"); + assertEquals(5, tokens.size()); + assertEquals(1, tokens.get(0).column()); + assertEquals(5, tokens.get(1).column()); + assertEquals(8, tokens.get(2).column()); + assertEquals(9, tokens.get(3).column()); + } + + @Test + void testUnterminatedStringThrows() { + TokenizerException ex = + assertThrows( + TokenizerException.class, () -> Tokenizer.tokenize("GREETING = 'unclosed")); + assertEquals(1, ex.getLine()); + assertEquals(12, ex.getColumn()); + assertTrue(ex.getMessage().contains("Unterminated string literal")); + } + + @Test + void testUnexpectedCharacterThrows() { + TokenizerException ex = + assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("CMD @ KEY=VALUE")); + assertTrue(ex.getMessage().contains("Unexpected character '@'")); + assertEquals(1, ex.getLine()); + } + + @Test + void testEmptyStringValue() { + List tokens = Tokenizer.tokenize("cmd = ''"); + assertEquals(4, tokens.size()); + assertEquals(RawTokenType.STRING, tokens.get(2).type()); + assertEquals("", tokens.get(2).value()); + } +}