Compare commits

...

16 Commits

Author SHA1 Message Date
Jona Walpert 6d1b467116 Fix: Sent chat messages with current username at send time 2026-04-15 21:06:13 +02:00
Jona Walpert 4687566816 Feat: Added client-side USERNAME_CHANGED event handling with whisper migration 2026-04-15 21:06:05 +02:00
Jona Walpert d27a07f3e8 Fix: Canonicalized outgoing chat sender name from authenticated session user 2026-04-15 21:05:40 +02:00
Jona Walpert b86f18a11c Feat: Added in-game player-id rename propagation for active games 2026-04-15 21:05:25 +02:00
Jona Walpert 7ff274269e Feat: Added lobby-level player rename handling and mapping synchronization 2026-04-15 21:04:57 +02:00
Jona Walpert ec916aa98c Fix: Allowed no-op username changes when new name equals current name 2026-04-15 21:04:38 +02:00
Jona Walpert 11be6e2b9b Feat: Wired lobby controller login flow to support in-session username changes 2026-04-15 20:43:15 +02:00
Jona Walpert a8599cfb4c Feat: Added CHANGE_USERNAME request support in LobbyClient 2026-04-15 20:42:50 +02:00
Jona Walpert 08b9f652be Feat: Added runtime shared-username update helper in client bootstrap 2026-04-15 20:42:28 +02:00
Jona Walpert 3252c7e534 Feat: Enabled username input section in lobby UI 2026-04-15 20:41:31 +02:00
Jona Walpert 497820db05 Merge branch 'fix/lobby-auto-cleanup' into 'main'
Feat: Auto-cleanup lobbies when game finishes

See merge request cs108-fs26/Gruppe-13!129
2026-04-14 21:35:43 +00:00
Jona Walpert 88c9431bf0 Feat: Auto-cleanup lobbies when game finishes
- Add countNonFoldedPlayers() to GameState for fold-only victory detection
- Auto-transition to FINISHED phase when betting ends
- Clean up lobby (remove players, reset gameController) when game finishes
- Lobby now switches from RUNNING to CREATED and disappears immediately

Fixes: lobby not disappearing after game ends
2026-04-14 23:33:01 +02:00
Jona Walpert 5a53a14933 Merge branch 'feat/use-real-lobby-id' into 'main'
Feat: uses real lobby id and not hardcoded id

See merge request cs108-fs26/Gruppe-13!128
2026-04-14 21:31:12 +00:00
Jona Walpert f7f66ec5db Feat: uses real lobby id and not hardcoded id 2026-04-14 23:15:39 +02:00
Jona Walpert d85b3d21ee Merge branch 'feat/117-send-winner-with-get-gamestate-command' into 'main'
Feat: Winner is now sent to the frontend with get_gamestate

Closes #117

See merge request cs108-fs26/Gruppe-13!127
2026-04-14 21:13:57 +00:00
Jona Walpert 382383d38b Feat: Winenr is now sent to the frontend with get_gamestate 2026-04-14 23:09:48 +02:00
19 changed files with 577 additions and 64 deletions
@@ -39,6 +39,10 @@ public class ClientApp {
return sharedUsername; return sharedUsername;
} }
public static void updateSharedUsername(String username) {
setSharedUsername(username != null && !username.isBlank() ? username.trim() : null);
}
private static void setSharedUsername(String username) { private static void setSharedUsername(String username) {
sharedUsername = username; sharedUsername = username;
LOGGER.info("sharedUsername set to '{}'", getSharedUsername()); LOGGER.info("sharedUsername set to '{}'", getSharedUsername());
@@ -1,15 +1,20 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat; package ch.unibas.dmi.dbis.cs108.casono.client.chat;
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.server.network.command.parsing.RequestParameter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.Timer; import java.util.Timer;
import java.util.TimerTask; import java.util.TimerTask;
import java.util.WeakHashMap; import java.util.WeakHashMap;
import java.util.function.Consumer;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
@@ -24,7 +29,7 @@ public class ChatController {
private static final Map<ClientService, ChatController> ACTIVE_CONTROLLERS = private static final Map<ClientService, ChatController> ACTIVE_CONTROLLERS =
new WeakHashMap<>(); new WeakHashMap<>();
private final String username; private volatile String username;
private final ClientService clientService; private final ClientService clientService;
@@ -37,6 +42,7 @@ public class ChatController {
private final ChatBoxController chatBoxController; private final ChatBoxController chatBoxController;
private int lobbyId = -1; private int lobbyId = -1;
private final Timer timer; private final Timer timer;
private final Consumer<List<String>> serverEventListener;
public record ChatKey(ChatType type, @Nullable String targetUser) { public record ChatKey(ChatType type, @Nullable String targetUser) {
public ChatKey(ChatType type) { public ChatKey(ChatType type) {
@@ -71,8 +77,10 @@ public class ChatController {
localUserList = new ArrayList<>(); localUserList = new ArrayList<>();
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;
registerAsActiveController(clientService); registerAsActiveController(clientService);
clientService.addEventListener(serverEventListener);
this.timer = new Timer(true); this.timer = new Timer(true);
timer.schedule( timer.schedule(
@@ -131,6 +139,7 @@ public class ChatController {
* <p>All Messages get added to a particular {@link ChatModel}, if the checks passed. * <p>All Messages get added to a particular {@link ChatModel}, if the checks passed.
*/ */
public void receiveMessage() { public void receiveMessage() {
String currentUsername = getCurrentUsername();
List<Message> newMessages = chatClient.getMessages(); List<Message> newMessages = chatClient.getMessages();
if (!newMessages.isEmpty()) { if (!newMessages.isEmpty()) {
for (Message msg : newMessages) { for (Message msg : newMessages) {
@@ -146,16 +155,17 @@ public class ChatController {
(_key) -> (_key) ->
new ChatModel( new ChatModel(
ChatType.LOBBY, ChatType.LOBBY,
username, currentUsername,
msg.lobbyId, msg.lobbyId,
null)) null))
.addMessage(msg); .addMessage(msg);
} }
break; break;
case ChatType.WHISPER: case ChatType.WHISPER:
if (msg.target.equals(username) || msg.sender.equals(username)) { if (msg.target.equals(currentUsername)
|| msg.sender.equals(currentUsername)) {
ChatKey key; ChatKey key;
if (msg.target.equals(username)) { if (msg.target.equals(currentUsername)) {
key = new ChatKey(ChatType.WHISPER, msg.sender); key = new ChatKey(ChatType.WHISPER, msg.sender);
} else { } else {
key = new ChatKey(ChatType.WHISPER, msg.target); key = new ChatKey(ChatType.WHISPER, msg.target);
@@ -166,7 +176,7 @@ public class ChatController {
ChatModel chatModel = ChatModel chatModel =
new ChatModel( new ChatModel(
ChatType.WHISPER, ChatType.WHISPER,
username, currentUsername,
lobbyId, lobbyId,
key.targetUser()); key.targetUser());
chatBoxController.addWhisperChat(key.targetUser(), chatModel); chatBoxController.addWhisperChat(key.targetUser(), chatModel);
@@ -203,13 +213,29 @@ public class ChatController {
*/ */
public synchronized void checkWhisperUsers() { public synchronized void checkWhisperUsers() {
List<String> users = chatClient.getUsers(); List<String> users = chatClient.getUsers();
logger.info(users); Set<String> remoteUsers = new HashSet<>();
if (!users.isEmpty()) { String currentUsername = getCurrentUsername();
for (String user : users) { for (String user : users) {
String value = user.split("\\=")[1]; if (user == null || !user.contains("=")) {
logger.info(value); continue;
addWhisperUser(value);
} }
String value = user.split("\\=", 2)[1].trim();
if (value.isBlank() || value.equals(currentUsername)) {
continue;
}
remoteUsers.add(value);
}
for (String known : new ArrayList<>(localUserList)) {
if (!remoteUsers.contains(known)) {
localUserList.remove(known);
chatBoxController.removeWhisperUser(known);
}
}
for (String remote : remoteUsers) {
addWhisperUser(remote);
} }
} }
@@ -222,16 +248,110 @@ public class ChatController {
if (user == null || user.isBlank()) { if (user == null || user.isBlank()) {
return; return;
} }
if (!(localUserList.contains(user) || user.equals(username))) { if (!(localUserList.contains(user) || user.equals(getCurrentUsername()))) {
localUserList.add(user); localUserList.add(user);
logger.info("adding new whisper user"); logger.info("adding new whisper user");
chatBoxController.addWhisperUser(user); chatBoxController.addWhisperUser(user);
} }
} }
public synchronized void updateUsername(String newUsername) {
if (newUsername == null || newUsername.isBlank()) {
return;
}
String oldUsername = this.username;
this.username = newUsername.trim();
chatBoxController.setUsername(this.username);
if (oldUsername != null && !oldUsername.equals(this.username)) {
localUserList.remove(oldUsername);
chatBoxController.removeWhisperUser(oldUsername);
}
}
private void handleServerEvent(List<String> lines) {
if (lines == null || lines.isEmpty()) {
return;
}
List<RequestParameter> params;
try {
params = ClientService.convertToRequestParameters(lines);
} catch (RuntimeException e) {
return;
}
String event = null;
String oldUsername = null;
String newUsername = null;
for (RequestParameter p : params) {
if ("EVENT".equalsIgnoreCase(p.key())) {
event = p.value();
} else if ("OLD_USERNAME".equalsIgnoreCase(p.key())) {
oldUsername = p.value();
} else if ("NEW_USERNAME".equalsIgnoreCase(p.key())) {
newUsername = p.value();
}
}
if (!"USERNAME_CHANGED".equalsIgnoreCase(event)) {
return;
}
applyUsernameMigration(oldUsername, newUsername);
}
private synchronized void applyUsernameMigration(String oldUsername, String newUsername) {
if (oldUsername == null
|| newUsername == null
|| oldUsername.isBlank()
|| newUsername.isBlank()
|| oldUsername.equals(newUsername)) {
return;
}
String currentUsername = getCurrentUsername();
if (oldUsername.equals(currentUsername)) {
updateUsername(newUsername);
}
ChatKey oldKey = new ChatKey(ChatType.WHISPER, oldUsername);
ChatKey newKey = new ChatKey(ChatType.WHISPER, newUsername);
ChatModel oldModel = chatModelMap.remove(oldKey);
ChatModel existingNewModel = chatModelMap.get(newKey);
if (oldModel != null) {
oldModel.setTarget(newUsername);
if (existingNewModel == null) {
chatModelMap.put(newKey, oldModel);
} else {
// Merge possible parallel history into the already existing new-key model.
for (Message msg : oldModel.messages) {
existingNewModel.addMessage(msg);
}
}
chatBoxController.renameWhisperUser(oldUsername, newUsername);
}
localUserList.remove(oldUsername);
chatBoxController.removeWhisperUser(oldUsername);
if (!newUsername.equals(getCurrentUsername())) {
addWhisperUser(newUsername);
}
}
public String getCurrentUsername() {
String shared = ClientApp.getSharedUsername();
if (shared != null && !shared.isBlank()) {
return shared.trim();
}
return username;
}
/** Stops polling background tasks for this chat controller instance. */ /** Stops polling background tasks for this chat controller instance. */
public void shutdown() { public void shutdown() {
timer.cancel(); timer.cancel();
clientService.removeEventListener(serverEventListener);
synchronized (ACTIVE_CONTROLLERS) { synchronized (ACTIVE_CONTROLLERS) {
if (ACTIVE_CONTROLLERS.get(clientService) == this) { if (ACTIVE_CONTROLLERS.get(clientService) == this) {
ACTIVE_CONTROLLERS.remove(clientService); ACTIVE_CONTROLLERS.remove(clientService);
@@ -22,7 +22,7 @@ public class ChatModel {
public final String username; public final String username;
/** The person to send the message to If the chat is a whisper chat */ /** The person to send the message to If the chat is a whisper chat */
private final String target; private String target;
private final IntegerProperty count; private final IntegerProperty count;
@@ -82,4 +82,8 @@ public class ChatModel {
public String getTarget() { public String getTarget() {
return target; return target;
} }
public void setTarget(String target) {
this.target = target;
}
} }
@@ -131,6 +131,29 @@ public class LobbyClient {
return new LoginResult(assigned, id); return new LoginResult(assigned, id);
} }
/**
* Changes the username for the currently logged-in session.
*
* @param newUsername desired new username
* @return a {@link LoginResult} containing assigned username and id as returned by the server
*/
public LoginResult changeUsername(String newUsername) {
List<String> lines = client.processCommand("CHANGE_USERNAME USERNAME=" + newUsername);
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
String assigned = newUsername;
String id = null;
for (RequestParameter p : params) {
if ("USERNAME".equalsIgnoreCase(p.key())) {
assigned = p.value();
} else if ("ID".equalsIgnoreCase(p.key())) {
id = p.value();
}
}
return new LoginResult(assigned, id);
}
/** /**
* Request the server for the list of available lobbies. * Request the server for the list of available lobbies.
* *
@@ -60,6 +60,12 @@ public class ChatBoxController {
usernameTabMap = new HashMap<>(); usernameTabMap = new HashMap<>();
} }
public void setUsername(String username) {
if (username != null && !username.isBlank()) {
this.username = username.trim();
}
}
/** /**
* Initializes the chat interface by creating the global chat model and adding the corresponding * 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 * "GLOBAL" tab to the interface. It also registers the global chat in the {@link
@@ -84,6 +90,11 @@ public class ChatBoxController {
public void addWhisperUser(String targetUserName) { public void addWhisperUser(String targetUserName) {
Platform.runLater( Platform.runLater(
() -> { () -> {
for (MenuItem existing : addWhisperChatButton.getItems()) {
if (targetUserName.equals(existing.getText())) {
return;
}
}
MenuItem menuItem = new MenuItem(targetUserName); MenuItem menuItem = new MenuItem(targetUserName);
addWhisperChatButton.getItems().add(menuItem); addWhisperChatButton.getItems().add(menuItem);
menuItem.setOnAction( menuItem.setOnAction(
@@ -98,6 +109,54 @@ public class ChatBoxController {
}); });
} }
public void removeWhisperUser(String targetUserName) {
Platform.runLater(
() ->
addWhisperChatButton
.getItems()
.removeIf(item -> targetUserName.equals(item.getText())));
}
public void renameWhisperUser(String oldUsername, String newUsername) {
if (oldUsername == null
|| newUsername == null
|| oldUsername.isBlank()
|| newUsername.isBlank()
|| oldUsername.equals(newUsername)) {
return;
}
Platform.runLater(
() -> {
for (MenuItem item : addWhisperChatButton.getItems()) {
if (oldUsername.equals(item.getText())) {
item.setText(newUsername);
item.setOnAction(
event ->
addWhisperChat(
newUsername,
new ChatModel(
ChatType.WHISPER,
username,
-1,
newUsername)));
break;
}
}
Tab tab = usernameTabMap.remove(oldUsername);
if (tab != null) {
tab.setText(newUsername);
usernameTabMap.put(newUsername, tab);
}
int idx = activeWhisperChats.indexOf(oldUsername);
if (idx >= 0) {
activeWhisperChats.set(idx, newUsername);
}
});
}
/** /**
* Creates a new private (whisper) chat model for a specific target user, registers it within * Creates a new private (whisper) chat model for a specific target user, registers it within
* the chat system, and opens a new chat tab. * the chat system, and opens a new chat tab.
@@ -68,11 +68,12 @@ public class ChatViewController implements Initializable {
String message = inputField.getText().trim(); String message = inputField.getText().trim();
if (!message.isEmpty()) { if (!message.isEmpty()) {
inputField.clear(); inputField.clear();
String currentUsername = controller.getCurrentUsername();
Message msg = Message msg =
new Message( new Message(
chatModel.getChattype(), chatModel.getChattype(),
chatModel.lobbyId, chatModel.lobbyId,
username, currentUsername,
chatModel.getTarget(), chatModel.getTarget(),
message); message);
controller.onSendToNetwork(msg); controller.onSendToNetwork(msg);
@@ -114,8 +114,11 @@ public class CasinoGameUI extends Application {
Parent root = fxmlLoader.load(); Parent root = fxmlLoader.load();
CasinoGameController controller = fxmlLoader.getController(); CasinoGameController controller = fxmlLoader.getController();
int gameId = 1; // TODO echte gameId einsetzen if (lobbyId <= 0) {
GameClient gameClient = new GameClient(clientService, gameId); throw new IllegalStateException("CasinoGameUI: lobbyId must be set before start()");
}
GameClient gameClient = new GameClient(clientService, lobbyId);
GameService gameService = new GameService(gameClient); GameService gameService = new GameService(gameClient);
controller.setGameService(gameService); controller.setGameService(gameService);
@@ -104,6 +104,13 @@ public class CasinomainuiController {
casinoTable.getChildren().add(gridManager.getGridPane()); casinoTable.getChildren().add(gridManager.getGridPane());
gridManager.renderLobbyButtons(); gridManager.renderLobbyButtons();
String sharedUsername = ClientApp.getSharedUsername();
updateUsernameFieldPresentation(sharedUsername);
if (loginButton != null) {
loginButton.setText(
sharedUsername != null && !sharedUsername.isBlank() ? "Change Name" : "Login");
}
initializeChat(clientService); initializeChat(clientService);
} }
@@ -156,7 +163,7 @@ public class CasinomainuiController {
/** Handles the login button action. Validates input and calls LobbyClient.login(). */ /** Handles the login button action. Validates input and calls LobbyClient.login(). */
@FXML @FXML
public void handleLoginButton() { public void handleLoginButton() {
String username = usernameField.getText(); String username = usernameField == null ? null : usernameField.getText();
if (username == null || username.isBlank()) { if (username == null || username.isBlank()) {
showAlert("Please enter a username."); showAlert("Please enter a username.");
return; return;
@@ -170,12 +177,63 @@ public class CasinomainuiController {
showAlert("Offline mode: cannot send login to server."); showAlert("Offline mode: cannot send login to server.");
return; return;
} }
String trimmed = username.trim();
try { try {
lobbyClient.login(username); var result = lobbyClient.login(trimmed);
showAlert("Login sent: " + username); String assigned = result != null ? result.getUsername() : trimmed;
ClientApp.updateSharedUsername(assigned);
if (chatController != null) {
chatController.updateUsername(assigned);
}
updateUsernameFieldPresentation(assigned);
if (loginButton != null) {
loginButton.setText("Change Name");
}
return;
} catch (RuntimeException loginError) {
if (!containsProtocolError(loginError, "ALREADY_LOGGED_IN")) {
LOGGER.error("Login failed: {}", loginError.getMessage());
showAlert("Login failed: " + loginError.getMessage());
return;
}
LOGGER.info("Session already logged in, trying username change");
}
try {
var result = lobbyClient.changeUsername(trimmed);
String assigned = result != null ? result.getUsername() : trimmed;
ClientApp.updateSharedUsername(assigned);
if (chatController != null) {
chatController.updateUsername(assigned);
}
updateUsernameFieldPresentation(assigned);
if (loginButton != null) {
loginButton.setText("Change Name");
}
} catch (RuntimeException e) { } catch (RuntimeException e) {
LOGGER.error("Login failed: {}", e.getMessage()); LOGGER.error("Change username failed: {}", e.getMessage());
showAlert("Login failed: " + e.getMessage()); showAlert("Change username failed: " + e.getMessage());
}
}
private boolean containsProtocolError(RuntimeException error, String code) {
String msg = error.getMessage();
return msg != null && msg.contains(code);
}
private void updateUsernameFieldPresentation(String username) {
if (usernameField == null) {
return;
}
if (username != null && !username.isBlank()) {
usernameField.setText(username.trim());
usernameField.positionCaret(usernameField.getText().length());
usernameField.setPromptText("Username");
} else {
usernameField.clear();
usernameField.setPromptText("Username");
} }
} }
@@ -1,6 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state; package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck; import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
@@ -31,8 +32,10 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
String username = resolveUsername(request); String username = resolveUsername(request);
Lobby lobby; Lobby lobby;
LobbyId lobbyId;
if (gameId != null) { if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId)); lobbyId = LobbyId.of(gameId);
lobby = lobbyManager.getLobby(lobbyId);
if (lobby == null) { if (lobby == null) {
responseDispatcher.dispatch( responseDispatcher.dispatch(
new ErrorResponse( new ErrorResponse(
@@ -59,6 +62,7 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
request.getContext(), "NOT_IN_LOBBY", "User not in a lobby")); request.getContext(), "NOT_IN_LOBBY", "User not in a lobby"));
return; return;
} }
lobbyId = lobby.getId();
} }
var game = lobby.getGameController(); var game = lobby.getGameController();
@@ -69,9 +73,30 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
return; return;
} }
if (game.getState().getPhase() == GamePhase.FINISHED) {
cleanupLobby(lobby, lobbyId);
}
responseDispatcher.dispatch(new GetGameStateResponse(request.getContext(), game, username)); responseDispatcher.dispatch(new GetGameStateResponse(request.getContext(), game, username));
} }
/**
* Cleans up a lobby by removing all players and resetting the game controller. This is called
* when the game reaches FINISHED phase.
*/
private void cleanupLobby(Lobby lobby, LobbyId lobbyId) {
try {
// Remove all players from the lobby
for (String playerName : lobby.getPlayerNames()) {
lobbyManager.removePlayer(playerName);
}
// Reset the game controller so the lobby returns to CREATED state
lobby.initGame(null);
} catch (RuntimeException e) {
// Log silently to avoid disrupting game state response
}
}
private String resolveUsername(GetGameStateRequest request) { private String resolveUsername(GetGameStateRequest request) {
String username = request.getUsername(); String username = request.getUsername();
if (username != null && !username.isBlank()) { if (username != null && !username.isBlank()) {
@@ -6,6 +6,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Rank;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Suit; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Suit;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.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.SuccessResponse;
@@ -33,11 +34,13 @@ public class GetGameStateResponse extends SuccessResponse {
super( super(
context, context,
buildBody( buildBody(
game,
Objects.requireNonNull(game, "game must not be null").getState(), Objects.requireNonNull(game, "game must not be null").getState(),
requestingUsername)); requestingUsername));
} }
private static ResponseBody buildBody(GameState state, String requestingUsername) { private static ResponseBody buildBody(
GameController game, GameState state, String requestingUsername) {
Objects.requireNonNull(state, "state must not be null"); Objects.requireNonNull(state, "state must not be null");
ResponseBodyBuilder builder = ResponseBody.builder(); ResponseBodyBuilder builder = ResponseBody.builder();
@@ -47,6 +50,7 @@ public class GetGameStateResponse extends SuccessResponse {
builder.param("CURRENT_BET", computeGlobalCurrentBet(state)); builder.param("CURRENT_BET", computeGlobalCurrentBet(state));
builder.param("DEALER", state.getDealerIndex()); builder.param("DEALER", state.getDealerIndex());
builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex()); builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex());
builder.param("WINNER", computeWinnerIndex(state, game));
appendCommunityCards(builder, state); appendCommunityCards(builder, state);
@@ -55,6 +59,28 @@ public class GetGameStateResponse extends SuccessResponse {
return builder.build(); return builder.build();
} }
private static int computeWinnerIndex(GameState state, GameController game) {
GamePhase phase = state.getPhase();
if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) {
return -1;
}
PlayerId winnerId = game.determineWinner();
if (winnerId == null) {
return -1;
}
int index = 0;
for (Player p : state.getPlayers()) {
if (p != null && winnerId.equals(p.getId())) {
return index;
}
index++;
}
return -1;
}
private static int computeGlobalCurrentBet(GameState state) { private static int computeGlobalCurrentBet(GameState state) {
int globalCurrentBet = 0; int globalCurrentBet = 0;
for (Player p : state.getPlayers()) { for (Player p : state.getPlayers()) {
@@ -9,6 +9,7 @@ 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.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.OkResponse;
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 java.util.Optional;
public class SendMessageHandler extends CommandHandler<SendMessageRequest> { public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
private final UserRegistry userRegistry; private final UserRegistry userRegistry;
@@ -39,6 +40,11 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
@Override @Override
public void execute(SendMessageRequest request) { public void execute(SendMessageRequest request) {
Message message = request.getMessage(); Message message = request.getMessage();
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> senderUser =
userRegistry.getBySessionId(request.getSessionId());
if (senderUser.isPresent()) {
message.sender = senderUser.get().getName();
}
broadcast(request, message); broadcast(request, message);
OkResponse response = new OkResponse(request.getContext()); OkResponse response = new OkResponse(request.getContext());
responseDispatcher.dispatch(response); responseDispatcher.dispatch(response);
@@ -61,6 +61,35 @@ public class GameController {
engine.getState().addPlayer(name, chips); engine.getState().addPlayer(name, chips);
} }
/**
* Renames a player id in the controller list and underlying game state.
*
* @param oldId old player id
* @param newId new player id
* @return true if rename succeeded
*/
public boolean renamePlayer(PlayerId oldId, PlayerId newId) {
if (oldId == null || newId == null) {
return false;
}
if (oldId.equals(newId)) {
return true;
}
int idx = players.indexOf(oldId);
if (idx < 0 || players.contains(newId)) {
return false;
}
boolean stateRenamed = engine.getState().renamePlayerId(oldId, newId);
if (!stateRenamed) {
return false;
}
players.set(idx, newId);
return true;
}
/** /**
* Initializes a new hand by preparing the deck, setting the phase to PREFLOP, rotating the * Initializes a new hand by preparing the deck, setting the phase to PREFLOP, rotating the
* dealer, dealing hole cards, posting blinds, and setting the first active player. * dealer, dealing hole cards, posting blinds, and setting the first active player.
@@ -254,4 +283,12 @@ public class GameController {
return bestPlayer; return bestPlayer;
} }
/**
* Ends the current game by setting the phase to FINISHED. This should be called when the
* showdown is complete and a winner has been determined.
*/
public void endGame() {
engine.getState().setPhase(GamePhase.FINISHED);
}
} }
@@ -49,6 +49,11 @@ public class RoundManager {
state.setPhase(GamePhase.PREFLOP); state.setPhase(GamePhase.PREFLOP);
} }
if (state.countNonFoldedPlayers() == 1) {
state.setPhase(GamePhase.FINISHED);
return;
}
if (isBettingRoundFinished(state)) { if (isBettingRoundFinished(state)) {
advancePhase(state); advancePhase(state);
} }
@@ -81,8 +86,9 @@ public class RoundManager {
case FLOP -> dealTurn(state); case FLOP -> dealTurn(state);
case TURN -> dealRiver(state); case TURN -> dealRiver(state);
case RIVER -> showdown(state); case RIVER -> showdown(state);
case SHOWDOWN -> { case SHOWDOWN -> state.setPhase(GamePhase.FINISHED);
/* nothing */ case FINISHED -> {
/* game is over */
} }
} }
} }
@@ -49,6 +49,16 @@ public class Player {
return id; return id;
} }
/**
* Updates the player's id. This is used when a username change is propagated into an already
* running game.
*
* @param id new player id
*/
public void setId(PlayerId id) {
this.id = id;
}
/** /**
* Returns the display name of the player. Currently identical to the player ID. * Returns the display name of the player. Currently identical to the player ID.
* *
@@ -11,18 +11,14 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* The GameState class encapsulates the entire state of a poker game at any * The GameState class encapsulates the entire state of a poker game at any given moment.
* given moment.
* *
* <p> * <p>Important invariants this implementation maintains:
* Important invariants this implementation maintains:
* *
* <ul> * <ul>
* <li>{@code playerOrder} defines the stable seating/turn order. * <li>{@code playerOrder} defines the stable seating/turn order.
* <li>{@code currentBets} always contains an entry for every player in * <li>{@code currentBets} always contains an entry for every player in {@code playerOrder}.
* {@code playerOrder}. * <li>{@code holeCards} maps every player to a mutable list; if not present, it is created on
* <li>{@code holeCards} maps every player to a mutable list; if not present, it
* is created on
* demand. * demand.
* </ul> * </ul>
*/ */
@@ -178,6 +174,51 @@ public class GameState {
holeCards.computeIfAbsent(id, k -> new ArrayList<>()); holeCards.computeIfAbsent(id, k -> new ArrayList<>());
} }
/**
* Renames a player id across all game-state structures.
*
* @param oldId existing player id
* @param newId new player id
* @return true if the rename was applied, false otherwise
*/
public synchronized boolean renamePlayerId(PlayerId oldId, PlayerId newId) {
if (oldId == null || newId == null) {
return false;
}
if (!players.containsKey(oldId)) {
return false;
}
if (oldId.equals(newId)) {
return true;
}
if (players.containsKey(newId)) {
return false;
}
Player player = players.remove(oldId);
if (player == null) {
return false;
}
player.setId(newId);
players.put(newId, player);
int idx = playerOrder.indexOf(oldId);
if (idx >= 0) {
playerOrder.set(idx, newId);
}
moveMapEntry(currentBets, oldId, newId, 0);
moveMapEntry(playerBetCommitments, oldId, newId, 0);
moveMapEntry(holeCards, oldId, newId, new ArrayList<>());
return true;
}
private <T> void moveMapEntry(
Map<PlayerId, T> map, PlayerId oldId, PlayerId newId, T fallback) {
T value = map.remove(oldId);
map.put(newId, value != null ? value : fallback);
}
// Betting // Betting
public int getCurrentBet(PlayerId playerId) { public int getCurrentBet(PlayerId playerId) {
return currentBets.getOrDefault(playerId, 0); return currentBets.getOrDefault(playerId, 0);
@@ -188,8 +229,7 @@ public class GameState {
} }
/** /**
* Reset bets to 0 for ALL players (do not clear the map). Also resets table * Reset bets to 0 for ALL players (do not clear the map). Also resets table current bet to 0.
* current bet to 0.
*/ */
public void resetBets() { public void resetBets() {
for (PlayerId id : playerOrder) { for (PlayerId id : playerOrder) {
@@ -232,8 +272,7 @@ public class GameState {
// Cards // Cards
/** /**
* Gives (overwrites) the two hole cards for the given player. Ensures stable * Gives (overwrites) the two hole cards for the given player. Ensures stable list identity
* list identity
* (important if other code holds references). * (important if other code holds references).
*/ */
public void giveHoleCards(PlayerId playerId, Card c1, Card c2) { public void giveHoleCards(PlayerId playerId, Card c1, Card c2) {
@@ -293,8 +332,7 @@ public class GameState {
/** /**
* Starts a new hand by resetting the game state for the next round of poker. * Starts a new hand by resetting the game state for the next round of poker.
* *
* <p> * <p>This:
* This:
* *
* <ul> * <ul>
* <li>sets {@code phase=PREFLOP} * <li>sets {@code phase=PREFLOP}
@@ -336,4 +374,19 @@ public class GameState {
public boolean isFolded(PlayerId playerId) { public boolean isFolded(PlayerId playerId) {
return getPlayer(playerId).isFolded(); return getPlayer(playerId).isFolded();
} }
/**
* Counts the number of players who have not folded.
*
* @return The number of non-folded players
*/
public int countNonFoldedPlayers() {
int count = 0;
for (Player p : players.values()) {
if (p != null && !p.isFolded()) {
count++;
}
}
return count;
}
} }
@@ -70,6 +70,30 @@ public class Lobby {
return playerNames.remove(playerName); return playerNames.remove(playerName);
} }
/**
* Renames a player in this lobby's player list.
*
* @param oldName old username
* @param newName new username
* @return true if renamed successfully
*/
public boolean renamePlayer(String oldName, String newName) {
if (oldName == null || newName == null) {
return false;
}
synchronized (playerNames) {
if (oldName.equals(newName)) {
return playerNames.contains(oldName);
}
int idx = playerNames.indexOf(oldName);
if (idx < 0 || playerNames.contains(newName)) {
return false;
}
playerNames.set(idx, newName);
return true;
}
}
public void initGame(GameController controller) { public void initGame(GameController controller) {
this.gameController = controller; this.gameController = controller;
} }
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby.AddResult; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby.AddResult;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@@ -170,6 +171,56 @@ public class LobbyManager {
} }
} }
/**
* Renames a player across lobby mapping, lobby player list and running game ids.
*
* @param oldUsername old username
* @param newUsername new username
* @return true if rename was applied
*/
public synchronized boolean renamePlayer(String oldUsername, String newUsername) {
if (oldUsername == null || newUsername == null) {
return false;
}
if (oldUsername.equals(newUsername)) {
return true;
}
LobbyId lobbyId = playerToLobby.get(oldUsername);
if (lobbyId == null) {
return true;
}
if (playerToLobby.containsKey(newUsername)) {
return false;
}
Lobby lobby = activeLobbies.get(lobbyId);
if (lobby == null) {
playerToLobby.remove(oldUsername);
return true;
}
boolean lobbyRenamed = lobby.renamePlayer(oldUsername, newUsername);
if (!lobbyRenamed) {
return false;
}
if (lobby.getGameController() != null) {
boolean gameRenamed =
lobby.getGameController()
.renamePlayer(PlayerId.of(oldUsername), PlayerId.of(newUsername));
if (!gameRenamed) {
// Best-effort rollback to keep structures consistent.
lobby.renamePlayer(newUsername, oldUsername);
return false;
}
}
playerToLobby.remove(oldUsername);
playerToLobby.put(newUsername, lobbyId);
return true;
}
/** /**
* Apply the given action to every player username in the lobby identified by {@code lobbyId}. * Apply the given action to every player username in the lobby identified by {@code lobbyId}.
* This is a small helper that keeps iteration logic centralized and avoids leaking internal * This is a small helper that keeps iteration logic centralized and avoids leaking internal
@@ -200,6 +200,10 @@ public class UserRegistry {
return false; return false;
} }
if (user.getName().equals(newName)) {
return true;
}
if (byName.containsKey(newName)) { if (byName.containsKey(newName)) {
return false; return false;
} }
@@ -81,25 +81,24 @@
</GridPane.margin> </GridPane.margin>
</Button> </Button>
<!-- Username input and login button --> <!-- Username input and login/change button -->
<!-- <HBox alignment="CENTER_LEFT" spacing="10"--> <HBox alignment="CENTER_LEFT" spacing="10"
<!-- GridPane.rowIndex="0"--> GridPane.rowIndex="0"
<!-- GridPane.columnIndex="0"--> GridPane.columnIndex="0"
<!-- GridPane.halignment="LEFT"--> GridPane.halignment="LEFT"
<!-- GridPane.valignment="TOP">--> GridPane.valignment="TOP">
<!-- <GridPane.margin>--> <GridPane.margin>
<!-- &lt;!&ndash; place below the 'CREATE A LOBBY' button &ndash;&gt;--> <Insets top="100" left="20" />
<!-- <Insets top="100" left="20" />--> </GridPane.margin>
<!-- </GridPane.margin>--> <TextField fx:id="usernameField"
<!-- <TextField fx:id="usernameField"--> promptText="Username"
<!-- promptText="Username"--> styleClass="gray-input-field"
<!-- styleClass="gray-input-field"--> maxWidth="180" />
<!-- maxWidth="180" />--> <Button text="Apply Name"
<!-- <Button text="Login"--> fx:id="loginButton"
<!-- fx:id="loginButton"--> onAction="#handleLoginButton"
<!-- onAction="#handleLoginButton"--> styleClass="button-create-lobby" />
<!-- styleClass="button-create-lobby" />--> </HBox>
<!-- </HBox>-->
<VBox fx:id="casinoTable" <VBox fx:id="casinoTable"
alignment="CENTER" alignment="CENTER"