Feat/chat tests #317

Merged
m.ginkel merged 14 commits from feat/chat-tests into main 2026-04-27 00:52:31 +02:00
12 changed files with 546 additions and 122 deletions
Showing only changes of commit 588bb1f312 - Show all commits
@@ -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.ChatClient;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; 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.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 ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -39,7 +41,11 @@ public class ChatController {
return chatBoxController; return chatBoxController;
} }
private final ChatBoxController chatBoxController; public void setChatBoxController(ChatBoxController chatBoxController) {
this.chatBoxController = chatBoxController;
}
private ChatBoxController chatBoxController;
private int lobbyId = -1; private int lobbyId = -1;
private final Timer timer; private final Timer timer;
private final Consumer<List<String>> serverEventListener; private final Consumer<List<String>> serverEventListener;
@@ -61,6 +67,12 @@ public class ChatController {
/** List of all users connected to the server, to safe them locally on the client */ /** List of all users connected to the server, to safe them locally on the client */
private final List<String> localUserList; private final List<String> localUserList;
public List<String> getLocalUserList() {
return this.localUserList;
}
public final Map<ChatController.ChatKey, ChatViewController> activeChatControllers;
private final Logger logger; private final Logger logger;
/** /**
@@ -78,6 +90,7 @@ public class ChatController {
this.chatBoxController = new ChatBoxController(username, this); this.chatBoxController = new ChatBoxController(username, this);
this.logger = LogManager.getLogger(ChatController.class); this.logger = LogManager.getLogger(ChatController.class);
this.serverEventListener = this::handleServerEvent; this.serverEventListener = this::handleServerEvent;
this.activeChatControllers = new HashMap<>();
registerAsActiveController(clientService); registerAsActiveController(clientService);
clientService.addEventListener(serverEventListener); clientService.addEventListener(serverEventListener);
@@ -126,7 +139,7 @@ public class ChatController {
} }
ChatModel lobbyChatModel = new ChatModel(ChatType.LOBBY, username, lobbyId, null); ChatModel lobbyChatModel = new ChatModel(ChatType.LOBBY, username, lobbyId, null);
chatModelMap.put(key, lobbyChatModel); chatModelMap.put(key, lobbyChatModel);
this.chatBoxController.addChatTab("Lobby", lobbyChatModel); this.chatBoxController.addChatTab("LOBBY", lobbyChatModel, ChatType.LOBBY);
} }
/** /**
@@ -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.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel; 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.ChatType;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import java.io.IOException; import java.io.IOException;
import java.net.URL; import java.net.URL;
import java.util.ArrayList; import java.util.ArrayList;
@@ -27,7 +28,7 @@ public class ChatBoxController {
private String username; private String username;
private ChatController chatController; private final ChatController chatController;
@FXML private VBox chatBox; @FXML private VBox chatBox;
@@ -43,8 +44,6 @@ public class ChatBoxController {
@FXML private final Map<String, Tab> usernameTabMap; @FXML private final Map<String, Tab> usernameTabMap;
private String ressource = "/ui-structure/components/chatui/chattab.fxml";
/** /**
* Constructor for the ChatBoxController, initializes the necessary fields and data structures * Constructor for the ChatBoxController, initializes the necessary fields and data structures
* for managing chat tabs and whisper chats. * for managing chat tabs and whisper chats.
@@ -73,11 +72,10 @@ public class ChatBoxController {
*/ */
@FXML @FXML
public void initialize() { public void initialize() {
ChatModel globalChatModel = new ChatModel(ChatType.GLOBAL, username, -1, null); ChatType global = ChatType.GLOBAL;
chatController ChatModel globalChatModel = new ChatModel(global, username, -1, null);
.getChatModelMap() chatController.getChatModelMap().put(new ChatController.ChatKey(global), globalChatModel);
.put(new ChatController.ChatKey(ChatType.GLOBAL), globalChatModel); addChatTab("GLOBAL", globalChatModel, global);
addChatTab("GLOBAL", globalChatModel);
addWhisperChatButton.setOnAction(event -> addWhisperChatButton.show()); addWhisperChatButton.setOnAction(event -> addWhisperChatButton.show());
} }
@@ -166,10 +164,11 @@ public class ChatBoxController {
public void addWhisperChat(String target, ChatModel chatModel) { public void addWhisperChat(String target, ChatModel chatModel) {
if (!activeWhisperChats.contains(target)) { if (!activeWhisperChats.contains(target)) {
activeWhisperChats.add(target); activeWhisperChats.add(target);
ChatType whisper = ChatType.WHISPER;
chatController chatController
.getChatModelMap() .getChatModelMap()
.put(new ChatController.ChatKey(ChatType.WHISPER, target), chatModel); .put(new ChatController.ChatKey(whisper, target), chatModel);
addChatTab(target, chatModel); addChatTab(target, chatModel, whisper);
} else { } else {
chatTabPane.getSelectionModel().select(usernameTabMap.get(target)); 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. * @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. * @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); URL resource = getClass().getResource(ressource);
FXMLLoader fxmlLoader = new FXMLLoader(resource); FXMLLoader fxmlLoader = new FXMLLoader(resource);
runOnPlatformSynchronized( runOnPlatformSynchronized(
@@ -193,6 +193,8 @@ public class ChatBoxController {
ChatViewController chatViewController = ChatViewController chatViewController =
new ChatViewController( new ChatViewController(
this.chatController, chatModel, this.username); this.chatController, chatModel, this.username);
chatController.activeChatControllers.put(
new ChatController.ChatKey(chatType), chatViewController);
fxmlLoader.setController(chatViewController); fxmlLoader.setController(chatViewController);
Node load = fxmlLoader.load(); Node load = fxmlLoader.load();
VBox.setVgrow(load, Priority.ALWAYS); VBox.setVgrow(load, Priority.ALWAYS);
@@ -239,4 +241,30 @@ public class ChatBoxController {
} }
} }
} }
public void loadChats() {
Map<ChatController.ChatKey, ChatModel> 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);
}
}
}
}
} }
@@ -18,15 +18,13 @@ import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger; import java.util.logging.Logger;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
import javafx.scene.Parent; import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView; import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane; import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.stage.Stage;
/** /**
* Controller for the casino gaming area. * Controller for the casino gaming area.
@@ -67,7 +65,9 @@ public class CasinoGameController {
private java.util.List<String> lastCommunityKeys = java.util.List.of(); private java.util.List<String> lastCommunityKeys = java.util.List.of();
private java.util.List<String> lastMyCardKeys = java.util.List.of(); private java.util.List<String> lastMyCardKeys = java.util.List.of();
private int lastPot = Integer.MIN_VALUE; 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 TOTAL_SLOTS = 5;
private static final int PLAYER_SLOTS = 2; private static final int PLAYER_SLOTS = 2;
private int pot = 0; private int pot = 0;
@@ -139,7 +139,6 @@ public class CasinoGameController {
private static final double CHAT_WIDTH = 400; private static final double CHAT_WIDTH = 400;
private static final double CHAT_HEIGHT = 600; private static final double CHAT_HEIGHT = 600;
private ChatController chatController;
private String chatUsername; private String chatUsername;
private ClientService chatClientService; private ClientService chatClientService;
private int chatLobbyId = -1; 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. */ /** Set the PlayerId of the current player. */
@FXML @FXML
public void initialize() { public void initialize() {
@@ -239,7 +243,8 @@ public class CasinoGameController {
// empty display only (optional) // empty display only (optional)
renderCommunityCards(List.of()); renderCommunityCards(List.of());
renderPlayerCards(List.of()); renderPlayerCards(List.of());
initializeChatIfPossible();
chatContainer.toFront();
} }
/** /**
@@ -257,7 +262,6 @@ public class CasinoGameController {
this.chatUsername = username; this.chatUsername = username;
this.chatClientService = clientService; this.chatClientService = clientService;
this.chatLobbyId = lobbyId; this.chatLobbyId = lobbyId;
initializeChatIfPossible();
} }
/** /**
@@ -265,42 +269,32 @@ public class CasinoGameController {
* is available and the chat has not already been initialized. * is available and the chat has not already been initialized.
*/ */
private void initializeChatIfPossible() { private void initializeChatIfPossible() {
if (chatInitialized || chatClientService == null || chatUsername == null) { if (chatInitialized
|| chatClientService == null
|| chatUsername == null
|| chatController == null) {
return; return;
} }
try { try {
chatController = new ChatController(chatUsername, chatClientService); chatController.updateUsername(chatUsername);
URL resource = getClass().getResource("/ui-structure/components/chatui/chatbox.fxml"); URL resource = getClass().getResource("/ui-structure/components/chatui/chatbox.fxml");
FXMLLoader loader = new FXMLLoader(resource); FXMLLoader loader = new FXMLLoader(resource);
loader.setController(chatController.getChatBoxController()); 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(); chatController.getChatBoxController().loadChats();
chatInitialized = true;
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();
if (chatLobbyId >= 0) { if (chatLobbyId >= 0) {
chatController.setLobbyChat(chatLobbyId); chatController.setLobbyChat(chatLobbyId);
} }
chatInitialized = true;
} catch (IOException e) { } catch (IOException e) {
LOGGER.warning("Could not initialize game chat UI: " + e.getMessage()); LOGGER.warning("Could not initialize game chat UI: " + e.getMessage());
} }
@@ -1,6 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui; 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.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.GameService;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId; import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; 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 final Logger LOG = Logger.getLogger(CasinoGameUI.class.getName());
private static ClientService clientService; private static ClientService clientService;
private static ChatController chatController;
private static String username; private static String username;
private static int lobbyId = -1; private static int lobbyId = -1;
@@ -56,6 +58,10 @@ public class CasinoGameUI extends Application {
CasinoGameUI.username = username; CasinoGameUI.username = username;
} }
public static void setChatController(ChatController chatController) {
CasinoGameUI.chatController = chatController;
}
/** /**
* Sets the lobby ID to be used by the application. * 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.setMyPlayerId(PlayerId.of(effectiveUsername));
controller.setChatContext(effectiveUsername, clientService, lobbyId); controller.setChatContext(effectiveUsername, clientService, lobbyId);
controller.startChat(chatController);
Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT); Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT);
stage.setTitle("Casono"); stage.setTitle("Casono");
@@ -141,6 +141,7 @@ public class CasinomainuiController {
AnchorPane.setBottomAnchor(chatNode, 0.0); AnchorPane.setBottomAnchor(chatNode, 0.0);
AnchorPane.setLeftAnchor(chatNode, 0.0); AnchorPane.setLeftAnchor(chatNode, 0.0);
AnchorPane.setRightAnchor(chatNode, 0.0); AnchorPane.setRightAnchor(chatNode, 0.0);
gridManager.setChatController(chatController);
} catch (IOException e) { } catch (IOException e) {
LOGGER.warn("Could not initialize lobby chat UI: {}", e.getMessage()); LOGGER.warn("Could not initialize lobby chat UI: {}", e.getMessage());
} }
@@ -1,6 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui; 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.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.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient; import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI; import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI;
@@ -43,6 +44,8 @@ public class LobbyButtonGridManager {
private final LobbyButtonTranslationManager translationManager; private final LobbyButtonTranslationManager translationManager;
private final LobbyClient lobbyClient; private final LobbyClient lobbyClient;
private ChatController chatController;
private final ConcurrentHashMap<String, Image> imageCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Image> imageCache = new ConcurrentHashMap<>();
private final ExecutorService executor = Executors.newCachedThreadPool(); private final ExecutorService executor = Executors.newCachedThreadPool();
@@ -557,6 +560,7 @@ public class LobbyButtonGridManager {
CasinoGameUI.setClientService(cs); CasinoGameUI.setClientService(cs);
CasinoGameUI.setLobbyId(lobbyId); CasinoGameUI.setLobbyId(lobbyId);
CasinoGameUI.setChatController(chatController);
String username = ClientApp.getSharedUsername(); String username = ClientApp.getSharedUsername();
if (username == null || username.isBlank()) { if (username == null || username.isBlank()) {
@@ -586,4 +590,8 @@ public class LobbyButtonGridManager {
public LobbyClient getLobbyClient() { public LobbyClient getLobbyClient() {
return lobbyClient; return lobbyClient;
} }
public void setChatController(ChatController chatController) {
this.chatController = chatController;
}
} }
@@ -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.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.SendMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest; 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.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob; 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.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.command.parsing.CommandParserDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent; 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.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.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.SessionDisconnectJob;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
import java.time.Duration; import java.time.Duration;
@@ -141,37 +138,12 @@ public class ServerApp {
userRegistry, userRegistry,
new CommandContext(lobbyManager, sessionManager)); new CommandContext(lobbyManager, sessionManager));
// Periodic cleanup: remove empty lobbies older than 30s and notify affected
// users
scheduler.scheduleAtFixedRate( scheduler.scheduleAtFixedRate(
() -> { new LobbyCleanupJob(
try { lobbyManager,
var expired = sessionManager,
lobbyManager.findEmptyLobbiesOlderThan( responseDispatcher,
Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)); 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);
}
},
LOBBY_CLEANUP_INITIAL_DELAY_SECONDS, LOBBY_CLEANUP_INITIAL_DELAY_SECONDS,
LOBBY_CLEANUP_PERIOD_SECONDS, LOBBY_CLEANUP_PERIOD_SECONDS,
TimeUnit.SECONDS); TimeUnit.SECONDS);
@@ -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.
*
* <p>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<LobbyId> 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);
}
}
}
}
@@ -48,7 +48,7 @@ public class TokenClassifier {
} }
private static void validateSeparator(List<RawToken> rawTokens, int index) { private static void validateSeparator(List<RawToken> 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 = boolean missingValue =
index + 1 >= rawTokens.size() index + 1 >= rawTokens.size()
@@ -76,12 +76,14 @@
<HBox fx:id="communityCardsBox" <HBox fx:id="communityCardsBox"
styleClass="community-cards-box" styleClass="community-cards-box"
alignment="CENTER" alignment="CENTER"
GridPane.columnIndex="2"
spacing="10"/> spacing="10"/>
<!-- Pot Box --> <!-- Pot Box -->
<HBox fx:id="potBox" <HBox fx:id="potBox"
alignment="CENTER" alignment="CENTER"
spacing="4" spacing="4"
GridPane.columnIndex="2"
maxWidth="Infinity" maxWidth="Infinity"
maxHeight="Infinity"/> maxHeight="Infinity"/>
@@ -99,6 +101,7 @@
styleClass="dealer-box" styleClass="dealer-box"
fitWidth="100" fitWidth="100"
fitHeight="100" fitHeight="100"
GridPane.columnIndex="2"
preserveRatio="true" preserveRatio="true"
visible="false"/> visible="false"/>
@@ -117,56 +120,48 @@
<!-- RIGHT SIDE (Chat Box) --> <!-- RIGHT SIDE (Chat Box) -->
<AnchorPane fx:id="chatContainer" GridPane.columnIndex="2" /> <AnchorPane fx:id="chatContainer" GridPane.columnIndex="2" />
<HBox AnchorPane.topAnchor="45"
AnchorPane.leftAnchor="0"
AnchorPane.rightAnchor="0"
alignment="CENTER"
spacing="300">
<VBox HBox.hgrow="ALWAYS"
alignment="TOP_LEFT"
translateX="50"
translateY="50">
<fx:include fx:id="player1"
source="gameuicomponents/Playerstatus.fxml"/>
</VBox>
<VBox HBox.hgrow="ALWAYS"
alignment="TOP_CENTER"
translateY="20">
<fx:include fx:id="player2"
source="gameuicomponents/Playerstatus.fxml"/>
</VBox>
<VBox HBox.hgrow="ALWAYS"
alignment="TOP_RIGHT"
translateX="-50"
translateY="50">
<fx:include fx:id="player3"
source="gameuicomponents/Playerstatus.fxml"/>
</VBox>
</HBox>
</children> </children>
</GridPane> </GridPane>
<!-- movable taskbar --> <!-- movable taskbar -->
<AnchorPane> <AnchorPane>
<fx:include fx:id="taskbarInclude" source="gameuicomponents/Taskbar.fxml"/> <fx:include fx:id="taskbarInclude" source="gameuicomponents/Taskbar.fxml" GridPane.columnIndex="1"/>
</AnchorPane> </AnchorPane>
<!-- Opponent Status 1: One of three panels displaying the opponent's icon, name, and account balance -->
<GridPane AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="50">
<columnConstraints>
<ColumnConstraints percentWidth="5"/>
<ColumnConstraints percentWidth="16"/>
</columnConstraints>
<children>
<fx:include fx:id="player1" source="gameuicomponents/Playerstatus.fxml" GridPane.columnIndex="1"/>
</children>
</GridPane>
<!-- Opponent Status 2: One of three panels displaying the opponent's icon, name, and account balance -->
<GridPane AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="20">
<columnConstraints>
<ColumnConstraints percentWidth="30"/>
<ColumnConstraints percentWidth="16"/>
</columnConstraints>
<children>
<fx:include fx:id="player2" source="gameuicomponents/Playerstatus.fxml" GridPane.columnIndex="1"/>
</children>
</GridPane>
<!-- Opponent Status 3: One of three panels that displays the opponent's icon, name, and account balance -->
<GridPane AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="50">
<columnConstraints>
<ColumnConstraints percentWidth="55"/>
<ColumnConstraints percentWidth="16"/>
</columnConstraints>
<children>
<fx:include fx:id="player3" source="gameuicomponents/Playerstatus.fxml" GridPane.columnIndex="1"/>
</children>
</GridPane>
</AnchorPane> </AnchorPane>
@@ -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<RawToken> raw = Tokenizer.tokenize("PING");
List<Token> 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<RawToken> raw = Tokenizer.tokenize("ANSWER VALUE=42");
List<Token> 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<RawToken> raw = Tokenizer.tokenize("GREET MSG='Hello World'");
List<Token> 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<RawToken> raw = Tokenizer.tokenize("CMD 'oops'");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertTrue(ex.getMessage().contains("Unexpected string literal"));
}
@Test
void testMissingValueAfterSeparatorThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD KEY=");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected VALUE after '='", ex.getMessage());
}
@Test
void testMissingKeyBeforeSeparatorThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD =VALUE");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected KEY before '='", ex.getMessage());
}
@Test
void testNextWordIsKeyThrows() {
List<RawToken> 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<RawToken> 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());
}
}
@@ -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<RawToken> tokens = Tokenizer.tokenize("");
assertEquals(1, tokens.size());
assertEquals(RawTokenType.EOF, tokens.get(0).type());
}
@Test
void testSimpleCommand() {
List<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> 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<RawToken> tokens = Tokenizer.tokenize("cmd = ''");
assertEquals(4, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(2).type());
assertEquals("", tokens.get(2).value());
}
}