From fb68ecc7778b2e28aaa0b7cde2421fa88a1f3faa Mon Sep 17 00:00:00 2001 From: Julian Kropff Date: Sun, 26 Apr 2026 16:58:23 +0200 Subject: [PATCH] Feat: New Game UI Features: - Call, Raise, Fold, Bet and Winner action are displayed in the Lobby Chat. - More security features in the Game UI - The Game UI is now even more Texas Hold'em friendly --- .../ui/gameui/CasinoGameController.java | 122 +++++-- .../gameuicomponents/TaskbarController.java | 343 ++++++++++++++++-- 2 files changed, 414 insertions(+), 51 deletions(-) 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 90d0fca..61d28bc 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 @@ -1,6 +1,8 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui; import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController; +import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatType; +import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message; import ch.unibas.dmi.dbis.cs108.casono.client.game.Card; import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService; import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState; @@ -142,14 +144,18 @@ public class CasinoGameController { private static final String ACTIVE_CARD_BOX_STYLE_CLASS = "player-cards-active-turn"; private static final int DEALER_OFFSET = 3; private static final String FIRST_PREFLOP_INFO_TEMPLATE = - "Set the big blind amount: %d$ or %d$"; + "Bet the big blind amount: %d$ or +%d$"; private String chatUsername; private ClientService chatClientService; private int chatLobbyId = -1; private boolean chatInitialized; private boolean gameFinished; + private boolean localWinAnnouncementSent; private String lastWinnerName; + private static final int SMALL_BLIND = 100; + private static final int BIG_BLIND = 200; + private static final int NO_BET = 0; /** Standard constructor. Used by FXML. */ public CasinoGameController() { @@ -201,11 +207,18 @@ public class CasinoGameController { if (controller != null && myPlayerId != null) { controller.setGameService(gameService, myPlayerId); } + configureTaskbarLobbyActionAnnouncements(); } + /** + * Set the PlayerId of the current player. + * + * @param chatController The ChatController instance used for managing the chat functionality. + */ public void startChat(ChatController chatController) { this.chatController = chatController; initializeChatIfPossible(); + configureTaskbarLobbyActionAnnouncements(); } /** Set the PlayerId of the current player. */ @@ -217,6 +230,7 @@ public class CasinoGameController { if (taskbarController != null && gameService != null && myPlayerId != null) { taskbarController.setGameService(gameService, myPlayerId); } + configureTaskbarLobbyActionAnnouncements(); if (communityCardsBox == null) { LOGGER.warning("communityCardsBox is NULL"); @@ -270,6 +284,7 @@ public class CasinoGameController { this.chatUsername = username; this.chatClientService = clientService; this.chatLobbyId = lobbyId; + configureTaskbarLobbyActionAnnouncements(); } /** @@ -512,6 +527,7 @@ public class CasinoGameController { highlightDealer(s); updateTaskbar(s); clearActiveTurnHighlights(); + announceLocalWinIfNeeded(s); finishGameUiLoop(); return; } @@ -683,11 +699,11 @@ public class CasinoGameController { * Returns a context-specific info message for the first player to act preflop. * * @param s The current game state. - * @return An English hint with the big blind amount and its double, or null if the special - * case does not apply. + * @return An English hint with the big blind amount and its double, or null if the special case + * does not apply. */ private String resolveFirstPreflopInfo(GameState s) { - if (s == null || myPlayerId == null || s.players == null || s.players.size() < 2) { + if (s == null || s.players == null || s.players.size() < 2) { return null; } @@ -695,22 +711,9 @@ public class CasinoGameController { return null; } - int myIndex = -1; - for (int i = 0; i < s.players.size(); i++) { - Player player = s.players.get(i); - if (player != null && myPlayerId.equals(player.getId())) { - myIndex = i; - break; - } - } - - if (myIndex < 0) { - return null; - } - int firstIndex = (s.players.size() == 2) ? s.dealer : (s.dealer + DEALER_OFFSET) % s.players.size(); - if (s.activePlayer != firstIndex || myIndex != firstIndex) { + if (s.activePlayer != firstIndex) { return null; } @@ -724,7 +727,8 @@ public class CasinoGameController { } long raiseTarget = (long) callTarget * 2L; - int safeRaiseTarget = raiseTarget > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) raiseTarget; + int safeRaiseTarget = + raiseTarget > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) raiseTarget; return String.format(FIRST_PREFLOP_INFO_TEMPLATE, callTarget, safeRaiseTarget); } @@ -746,11 +750,11 @@ public class CasinoGameController { } int bet = Math.max(0, player.getBet()); - if (bet == 100) { + if (bet == SMALL_BLIND) { sbCount++; - } else if (bet == 200) { + } else if (bet == BIG_BLIND) { bbCount++; - } else if (bet == 0) { + } else if (bet == NO_BET) { zeroCount++; } } @@ -1089,6 +1093,39 @@ public class CasinoGameController { return null; } + /** + * Sends a short lobby chat message when the local player wins, but only once per hand. + * + * @param s The current game state. + */ + private void announceLocalWinIfNeeded(GameState s) { + if (localWinAnnouncementSent || s == null || !isTerminalPhase(s.phase)) { + return; + } + + Player winner = resolveWinner(s); + boolean isLocalWinner = + winner != null + && winner.getId() != null + && myPlayerId != null + && myPlayerId.equals(winner.getId()); + + if (!isLocalWinner && lastWinnerName != null && !lastWinnerName.isBlank()) { + String localName = + (chatUsername != null && !chatUsername.isBlank()) + ? chatUsername + : (winner != null ? winner.getName() : null); + isLocalWinner = localName != null && localName.equalsIgnoreCase(lastWinnerName); + } + + if (!isLocalWinner) { + return; + } + + localWinAnnouncementSent = true; + sendLobbyActionMessage("I won"); + } + /** * Determine if the game is in a finished state based on the game state and the presence of a * winner name. @@ -1736,6 +1773,47 @@ public class CasinoGameController { if (controller != null && gameService != null && myPlayerId != null) { controller.setGameService(gameService, myPlayerId); } + configureTaskbarLobbyActionAnnouncements(); + } + + /** + * Configure the TaskbarController to announce lobby actions by sending chat messages when + * certain actions occur in the game, such as winning a hand. + */ + private void configureTaskbarLobbyActionAnnouncements() { + TaskbarController controller = resolveTaskbarController(); + if (controller == null) { + return; + } + controller.setLobbyActionAnnouncer(this::sendLobbyActionMessage); + } + + /** + * Send a lobby action message to the chat controller with the specified action label, which can + * be used to announce game events in the lobby chat. + * + * @param actionLabel The label describing the action to announce in the lobby chat, such as "I + * won" when the local player wins a hand. + */ + private void sendLobbyActionMessage(String actionLabel) { + if (chatController == null + || chatLobbyId < 0 + || actionLabel == null + || actionLabel.isBlank()) { + return; + } + + String sender = + (chatUsername != null && !chatUsername.isBlank()) + ? chatUsername + : chatController.getCurrentUsername(); + if (sender == null || sender.isBlank()) { + return; + } + + Message message = + new Message(ChatType.LOBBY, chatLobbyId, sender, null, actionLabel.trim()); + chatController.onSendToNetwork(message); } /** diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java index e72d51b..f393cb1 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/TaskbarController.java @@ -11,6 +11,8 @@ import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.Button; @@ -18,6 +20,8 @@ import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; import javafx.scene.control.Label; import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; @@ -71,6 +75,11 @@ public class TaskbarController { private static final double ALERT_LOGO_HEIGHT = 40.0; private boolean inputActionAllowed; private int lastReferenceBet = BIG_BLIND; + private Consumer lobbyActionAnnouncer; + private String lastPhase; + private static final int FIRST_PLAYER_MAX_BET = 3000; + private static final int MAX_INPUT_LENGTH = 6; + private static final double MAX_CHIP_PERCENT = 0.30; /** Standard constructor. Used by FXML. */ public TaskbarController() { @@ -223,6 +232,11 @@ public class TaskbarController { this.myPlayerId = myPlayerId; } + /** Sets an optional callback that publishes simple action labels to lobby chat. */ + public void setLobbyActionAnnouncer(Consumer lobbyActionAnnouncer) { + this.lobbyActionAnnouncer = lobbyActionAnnouncer; + } + /** * Initializes the taskbar controller. This method is called automatically after the FXML * components have been loaded. @@ -230,15 +244,102 @@ public class TaskbarController { @FXML public void initialize() { updateBasicButtons(false, false); + if (taskbarInput != null) { + + UnaryOperator filter = + change -> { + String newText = change.getControlNewText(); + + if (newText.length() > MAX_INPUT_LENGTH) { + return null; + } + + if (!newText.matches("\\d*")) { + return null; + } + + return change; + }; + + taskbarInput.setTextFormatter(new TextFormatter<>(filter)); + taskbarInput .textProperty() .addListener((obs, oldValue, newValue) -> refreshBetInputUi()); } + Tooltip callTooltip = new Tooltip(); + callButton.setTooltip(callTooltip); + callButton.setOnMouseEntered(e -> callTooltip.setText(previewCall())); + + Tooltip raiseTooltip = new Tooltip(); + raiseButton.setTooltip(raiseTooltip); + raiseButton.setOnMouseEntered(e -> raiseTooltip.setText(previewRaise())); + setBetButtonVisible(false); } + /** + * Generates a preview string for the Call action, showing the amount that would be called based + * on the current game state and the player's bet status. + * + * @return A string representing the Call action preview, including the amount that would be + * called, or an empty string if the game state is not available. + */ + private String previewCall() { + if (lastState == null) { + return ""; + } + + int callTarget = effectiveCallTarget(lastState); + Player me = findCurrentPlayer(lastState); + + int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + int amount = Math.max(0, callTarget - alreadyInvested); + + return "CALL " + amount; + } + + /** + * Generates a preview string for the Raise action, showing the amount that would be raised. + * + * @return A string representing the Raise action preview, including the amount that would be + * raised. + */ + private String previewRaise() { + if (lastState == null) { + return ""; + } + + // int callTarget = effectiveCallTarget(lastState); + // Player me = findCurrentPlayer(lastState); + + // int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + + // Integer targetBet = resolveTargetBet(ActionType.RAISE_BUTTON, lastState); + // if (targetBet == null) { + // return ""; + // } + + // int totalContribution = Math.max(0, targetBet); + + GameState state = ensureLatestStateForAction(ActionType.CALL_BUTTON.name().toLowerCase()); + + // int raiseBy = Math.max(0, targetBet); + int raiseBy = effectiveCallTarget(state); + + int callTarget = effectiveCallTarget(lastState); + Player me = findCurrentPlayer(lastState); + + int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + int amount = Math.max(0, callTarget - alreadyInvested); + + int raiseamout = amount + raiseBy; + + return "RAISE +" + raiseBy + " TO " + raiseamout; + } + /** * Updates the taskbar based on the current game state and the player's status. * @@ -258,6 +359,7 @@ public class TaskbarController { } this.lastState = state; + if (state.currentBet > 0) { lastReferenceBet = state.currentBet; } @@ -279,8 +381,14 @@ public class TaskbarController { boolean isOut = me.getState() == PlayerState.FOLDED; boolean isGameFinished = isHandFinished(state); + boolean phaseChanged = lastPhase == null || !lastPhase.equalsIgnoreCase(state.phase); + + lastPhase = state.phase; + updateBasicButtons(isMyTurn, isOut || isGameFinished); - applyActionAvailability(state, me, isMyTurn, isOut || isGameFinished); + + applyActionAvailability(state, me, isMyTurn, isOut || isGameFinished, phaseChanged); + setMoney(me.getChips()); refreshBetInputUi(); } @@ -297,7 +405,14 @@ public class TaskbarController { * if the game is finished, which disables actions. */ private void applyActionAvailability( - GameState state, Player me, boolean isMyTurn, boolean isOutOrFinished) { + GameState state, + Player me, + boolean isMyTurn, + boolean isOutOrFinished, + boolean phaseChanged) { + + // boolean firstPlayerNewRound = + // phaseChanged && isFirstPreflopPlayerInputOnly(state, me); inputActionAllowed = false; if (!isMyTurn || isOutOrFinished || state == null || me == null) { setBetButtonVisible(false); @@ -318,7 +433,17 @@ public class TaskbarController { return; } - if (isFirstPreflopPlayerInputOnly(state, me)) { + // if (isFirstPreflopPlayerInputOnly(state, me)) { + // setActionEnabled(betButton, true); + // setActionEnabled(callButton, false); + // setActionEnabled(foldButton, false); + // setActionEnabled(raiseButton, false); + // activateInputField(taskbarInput); + // inputActionAllowed = true; + // return; + // } + + if (isFirstPlayerOfPhase(state, me)) { setActionEnabled(betButton, true); setActionEnabled(callButton, false); setActionEnabled(foldButton, false); @@ -363,7 +488,16 @@ public class TaskbarController { if (value > Integer.MAX_VALUE / 2) { return Integer.MAX_VALUE; } - return value * 2; + + int callTarget = effectiveCallTarget(lastState); + Player me = findCurrentPlayer(lastState); + + int alreadyInvested = (me != null) ? Math.max(0, me.getBet()) : 0; + int amount = Math.max(0, callTarget - alreadyInvested); + + int raisevalue = amount + value; + + return raisevalue; } /** @@ -441,10 +575,22 @@ public class TaskbarController { * @return An integer representing the effective call target for the current game state. */ private int effectiveCallTarget(GameState state) { - if (state != null && state.currentBet > 0) { - return state.currentBet; + int currentBet = (state != null) ? Math.max(0, state.currentBet) : 0; + int rememberedBet = Math.max(0, lastReferenceBet); + + if (state != null && isPreflop(state.phase) && isInitialPreflopBlindLayout(state)) { + return currentBet; } - return Math.max(0, lastReferenceBet); + + if (currentBet <= 0) { + return rememberedBet; + } + + if (rememberedBet <= 0) { + return currentBet; + } + + return Math.max(currentBet, rememberedBet); } /** @@ -483,6 +629,46 @@ public class TaskbarController { return isInitialPreflopBlindLayout(state); } + /** + * Checks if the current player is the first to act in the current phase of the game, based on + * the dealer position and the active player index. + * + * @param state The current GameState object representing the state of the game, which includes + * information about + * @param me The Player object representing the current player, used to determine their + * position. + * @return A boolean value indicating whether the current player is the first to act in the + * current phase of the game. + */ + private boolean isFirstPlayerOfPhase(GameState state, Player me) { + + if (state == null || me == null || state.players == null) { + return false; + } + + int size = state.players.size(); + if (size < 2) { + return false; + } + + int myIndex = state.players.indexOf(me); + if (myIndex < 0) { + return false; + } + + int dealer = state.dealer; + + int firstIndex; + + if (isPreflop(state.phase)) { + firstIndex = (size == 2) ? dealer : (dealer + DEALER_OFFSET) % size; + } else { + firstIndex = (dealer + 1) % size; + } + + return state.activePlayer == firstIndex && myIndex == firstIndex; + } + /** * Checks if the initial blind layout is still in place during the pre-flop phase. * @@ -492,6 +678,10 @@ public class TaskbarController { * the pre-flop phase. */ private boolean isInitialPreflopBlindLayout(GameState state) { + if (state == null || state.players == null || state.players.isEmpty()) { + return false; + } + int sbCount = 0; int bbCount = 0; int zeroCount = 0; @@ -652,7 +842,7 @@ public class TaskbarController { return; } - executeAction(state, targetBet); + executeAction(state, targetBet, actionType); if (targetBet > 0) { lastReferenceBet = targetBet; @@ -682,7 +872,8 @@ public class TaskbarController { } if (actionType == ActionType.RAISE_BUTTON) { - return safeDouble(callTarget); + Integer input = parseInputTarget(taskbarInput.getText()); + return input != null ? input : callTarget + BIG_BLIND; } return parseInputTarget(taskbarInput.getText()); @@ -728,19 +919,78 @@ public class TaskbarController { * @param targetBet The integer value representing the target bet amount for the action being * executed, which is used to determine */ - private void executeAction(GameState state, int targetBet) { + private void executeAction(GameState state, int targetBet, ActionType actionType) { int callTarget = effectiveCallTarget(state); + String phase = (state != null ? state.phase : "UNKNOWN"); + + int alreadyInvested = + (findCurrentPlayer(state) != null) + ? Math.max(0, findCurrentPlayer(state).getBet()) + : 0; + + if (actionType == ActionType.CALL_BUTTON) { + + int amount = Math.max(0, callTarget - alreadyInvested); + + gameService.call(); + + LOGGER.info("[{}] CALL +{} (total to {})", phase, amount, callTarget); + announceLobbyAction("CALL " + amount); + + return; + } + + if (actionType == ActionType.RAISE_BUTTON) { + + int raiseBy = Math.max(0, targetBet - callTarget); + + gameService.raise(raiseBy); + + LOGGER.info("[{}] RAISE +{} (target={})", phase, raiseBy, targetBet); + announceLobbyAction("RAISE +" + raiseBy + " TO " + targetBet); + + return; + } if (state.currentBet <= 0) { + gameService.bet(targetBet); - LOGGER.info("BET {}", targetBet); - } else if (targetBet == callTarget) { + + LOGGER.info("[{}] BET {}", phase, targetBet); + announceLobbyAction("BET " + targetBet); + + return; + } + + if (targetBet == callTarget) { + + int amount = Math.max(0, callTarget - alreadyInvested); + gameService.call(); - LOGGER.info("CALL {}", targetBet); + + LOGGER.info("[{}] CALL +{} (total to {})", phase, amount, callTarget); + announceLobbyAction("CALL " + amount); + } else { - int raiseBy = Math.max(0, targetBet - state.currentBet); + + int raiseBy = Math.max(0, targetBet - callTarget); + gameService.raise(raiseBy); - LOGGER.info("RAISE {} (target={})", raiseBy, targetBet); + + LOGGER.info("[{}] RAISE +{} (target={})", phase, raiseBy, targetBet); + announceLobbyAction("RAISE +" + raiseBy + " TO " + targetBet); + } + } + + /** Publishes a simple action label to the lobby chat, if a publisher is configured. */ + private void announceLobbyAction(String actionLabel) { + if (lobbyActionAnnouncer == null || actionLabel == null || actionLabel.isBlank()) { + return; + } + try { + lobbyActionAnnouncer.accept(actionLabel.trim()); + } catch (RuntimeException ex) { + LOGGER.warn("Could not publish lobby action '{}': {}", actionLabel, ex.getMessage()); } } @@ -757,8 +1007,15 @@ public class TaskbarController { if (text == null || text.trim().isEmpty()) { return null; } + try { - return Integer.parseInt(text.trim()); + int value = Integer.parseInt(text.trim()); + + if (value % SMALL_BLIND != 0) { + return null; + } + + return value; } catch (NumberFormatException e) { return null; } @@ -803,21 +1060,44 @@ public class TaskbarController { * triggers a warning, or if it is blocked due to being invalid or too risky. */ private ValidationResult validateTarget(GameState state, Player me, int targetBet) { + if (state == null || me == null) { return ValidationResult.blocked("Invalid game state"); } - int callTarget = effectiveCallTarget(state); - int raiseTarget = safeDouble(callTarget); - if (targetBet != callTarget && targetBet != raiseTarget) { - return ValidationResult.blocked("Only calls or exactly two raises allowed"); + final int callTarget = effectiveCallTarget(state); + + final int alreadyInvested = Math.max(0, me.getBet()); + final int chips = Math.max(0, me.getChips()); + + final int required = Math.max(0, targetBet - alreadyInvested); + + final int minRequiredToCall = Math.max(0, callTarget - alreadyInvested); + + if (required < minRequiredToCall) { + return ValidationResult.blocked("Bet must match at least the call amount"); } - int alreadyInvested = Math.max(0, me.getBet()); - int required = Math.max(0, targetBet - alreadyInvested); - int chips = Math.max(0, me.getChips()); + if (isFirstPlayerOfPhase(state, me)) { + if (required > FIRST_PLAYER_MAX_BET) { + return ValidationResult.blocked( + "First player cannot bet more than " + FIRST_PLAYER_MAX_BET); + } + } - if (targetBet == callTarget && required == 0) { + if (isPreflop(state.phase)) { + int maxAllowed = (int) Math.floor(me.getChips() * MAX_CHIP_PERCENT); + + if (required > maxAllowed) { + return ValidationResult.blocked( + "Preflop: You can only bet up to 30% of your stack (" + maxAllowed + ")"); + } + } + + if (required == minRequiredToCall) { + if (required > chips) { + return ValidationResult.blocked("Not enough chips to call"); + } return ValidationResult.ok(); } @@ -826,16 +1106,19 @@ public class TaskbarController { } if (required > chips) { - return ValidationResult.blocked("Not enough chips for the next bet"); + return ValidationResult.blocked("Not enough chips for this bet"); } BetRisk risk = evaluateBetRisk(state, required, chips); + if (risk == BetRisk.BLOCKED) { - return ValidationResult.blocked("Assignment for this phase is blocked"); + return ValidationResult.blocked("Bet too risky for this phase"); } + if (risk == BetRisk.WARNING) { return ValidationResult.warning(warningTextForPhase(state)); } + return ValidationResult.ok(); } @@ -980,8 +1263,6 @@ public class TaskbarController { * Called while dragging the taskbar with the mouse. Updates the position and slightly scales * the taskbar for visual feedback. * - *

TODO: It still needs to be fixed that the taskbar cannot disappear out of the window. - * * @param event Das Mausereignis */ @FXML @@ -1099,7 +1380,7 @@ public class TaskbarController { /** Called when the Call button is clicked. */ @FXML private void onInputPlayerCall() { - submitPresetInputAndProcess("call"); + submitAction(ActionType.CALL_BUTTON); } /** Called when the Fold button is clicked. */ @@ -1120,6 +1401,7 @@ public class TaskbarController { gameService.fold(); LOGGER.info("Player FOLD"); + announceLobbyAction("FOLD"); refreshGame(); } @@ -1233,6 +1515,9 @@ public class TaskbarController { GameState refreshed = gameService.refresh(); if (refreshed != null) { lastState = refreshed; + if (refreshed.currentBet > 0 && refreshed.currentBet >= lastReferenceBet) { + lastReferenceBet = refreshed.currentBet; + } return refreshed; } } catch (Exception e) {