diff --git a/documents/diary/26-04.md b/documents/diary/26-04.md
new file mode 100644
index 0000000..3fb13bb
--- /dev/null
+++ b/documents/diary/26-04.md
@@ -0,0 +1,11 @@
+# Besprechung vom 26.04.2024
+
+## Fortschritt für die Implementierung der Unit Tests
+Die Unit Tests für alle zentralen Komponenten des Netzwerkes wurden erfolgreich implementiert.
+Bei dieser Gelegenheit wurden auch Bugs behoben und die Codequalität verbessert.
+
+In einzelnen Bereichen wurde noch nicht die vorgenommene Quote von 50% erreicht, weitere Unit-Tests sollen bis zum nächsten Meilenstein folgen. Komponenten beteiligt an den Kernbereichen sind größtenteils abgedeckt.
+
+## Perspektivische Änderungen für den letzten Meilenstein
+Ein getroffener Kompromiss um den vierten Meilenstein fristgerecht zu erreichen, war das zurückstellen der Codequalität.
+In der Zeit bis zum sechsten Meilenstein sollen diese Provisorien noch einmal überarbeitet und finalisiert werden.
diff --git a/documents/diary/README.md b/documents/diary/README.md
index 6304353..932870f 100644
--- a/documents/diary/README.md
+++ b/documents/diary/README.md
@@ -36,7 +36,9 @@ None, as milestone 3 covered the assessment of individual technical understandin
## Milestone 5
-*(Meeting notes to be added here when available)*
+### [26. April 2026](26-04.md)
+- Progress on unit tests and code quality improvements
+- Discussion of compromises made for MS4 and plans for finalizing code quality by MS6
## Milestone 6
diff --git a/documents/docs/game-engine/casono-markdown-render-engine.js b/documents/docs/game-engine/casono-markdown-render-engine.js
new file mode 100644
index 0000000..966dbba
--- /dev/null
+++ b/documents/docs/game-engine/casono-markdown-render-engine.js
@@ -0,0 +1,84 @@
+const marked = {
+ parse: function (md) {
+ if (!md) return "";
+
+ let toc = [];
+
+ md = md.replace(/^(#{1,3})\s+(.*)$/gim, (m, hashes, title) => {
+ const level = hashes.length;
+
+ const id = title
+ .toLowerCase()
+ .trim()
+ .replace(/[^\wäöüß]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+
+ toc.push({ level, title, id });
+
+ return `
+
+ ${title}
+
+ `;
+ });
+
+ md = md.replace(/```([\s\S]*?)```/g, (_, code) => {
+ return `
");
+}
+
+function escapeHtml(text) {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+window.marked = marked;
diff --git a/documents/docs/game-engine/images/activate-js.png b/documents/docs/game-engine/images/activate-js.png
new file mode 100644
index 0000000..dce5bc5
Binary files /dev/null and b/documents/docs/game-engine/images/activate-js.png differ
diff --git a/documents/docs/game-engine/manual.html b/documents/docs/game-engine/manual.html
new file mode 100644
index 0000000..27ca621
--- /dev/null
+++ b/documents/docs/game-engine/manual.html
@@ -0,0 +1,316 @@
+
+
+
+
+ Markdown Viewer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 f3ab44d..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,11 +1,14 @@
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;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
+import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController;
@@ -138,11 +141,21 @@ public class CasinoGameController {
private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 35L;
private static final double CHAT_WIDTH = 400;
private static final double CHAT_HEIGHT = 600;
+ 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 =
+ "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() {
@@ -194,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. */
@@ -210,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");
@@ -230,6 +251,7 @@ public class CasinoGameController {
}
myDealerIcon.setVisible(false);
+ tableText.setWrapText(true);
javafx.stage.Screen screen = javafx.stage.Screen.getPrimary();
double screenHeight = screen.getBounds().getHeight();
@@ -262,6 +284,7 @@ public class CasinoGameController {
this.chatUsername = username;
this.chatClientService = clientService;
this.chatLobbyId = lobbyId;
+ configureTaskbarLobbyActionAnnouncements();
}
/**
@@ -477,7 +500,7 @@ public class CasinoGameController {
*/
private void applyState(GameState s) {
- if (s == null) {
+ if (s == null || gameFinished) {
return;
}
@@ -490,27 +513,33 @@ public class CasinoGameController {
+ (s.players != null ? s.players.size() : 0));
List players = (s.players != null) ? s.players : List.of();
- List community = (s.communityCards != null) ? s.communityCards : List.of();
- List myCards = getMyCards(players);
-
- java.util.List newCommunityKeys = cardKeys(community, TOTAL_SLOTS);
- if (!newCommunityKeys.equals(lastCommunityKeys)) {
- lastCommunityKeys = newCommunityKeys;
- renderCommunityCards(community);
+ String winnerName = resolveWinnerName(s);
+ if (winnerName != null && !winnerName.isBlank()) {
+ lastWinnerName = winnerName;
+ } else if (!isTerminalPhase(s.phase)) {
+ lastWinnerName = null;
}
- java.util.List newMyCardKeys = cardKeys(myCards, PLAYER_SLOTS);
- if (!newMyCardKeys.equals(lastMyCardKeys)) {
- lastMyCardKeys = newMyCardKeys;
- renderPlayerCards(myCards);
+ if (isGameFinishedState(s, winnerName)) {
+ updatePlayers(players, -1);
+ syncWhisperTargetsFromPlayers(players);
+ updateGameInfo(s);
+ highlightDealer(s);
+ updateTaskbar(s);
+ clearActiveTurnHighlights();
+ announceLocalWinIfNeeded(s);
+ finishGameUiLoop();
+ return;
}
+ updateCards(s, players);
+
if (s.pot != lastPot) {
lastPot = s.pot;
renderPot(s.pot);
}
- updatePlayers(players);
+ updatePlayers(players, s.activePlayer);
syncWhisperTargetsFromPlayers(players);
updateGameInfo(s);
highlightDealer(s);
@@ -533,6 +562,32 @@ public class CasinoGameController {
}
}
+ /**
+ * Update the displayed community cards and the player's hole cards based on the current game
+ * state.
+ *
+ * @param s The current game state containing the community cards and the list of players, used
+ * to determine.
+ * @param players The list of players currently in the game, used to retrieve the player's hole
+ * cards for display. If null, it will be treated as an empty list.
+ */
+ private void updateCards(GameState s, List players) {
+ List community = (s.communityCards != null) ? s.communityCards : List.of();
+ List myCards = getMyCards(players);
+
+ var newCommunityKeys = cardKeys(community, TOTAL_SLOTS);
+ if (!newCommunityKeys.equals(lastCommunityKeys)) {
+ lastCommunityKeys = newCommunityKeys;
+ renderCommunityCards(community);
+ }
+
+ var newMyCardKeys = cardKeys(myCards, PLAYER_SLOTS);
+ if (!newMyCardKeys.equals(lastMyCardKeys)) {
+ lastMyCardKeys = newMyCardKeys;
+ renderPlayerCards(myCards);
+ }
+ }
+
/**
* Synchronize the whisper chat targets with the current list of players.
*
@@ -610,14 +665,102 @@ public class CasinoGameController {
*/
private void updateGameInfo(GameState s) {
- String text = "Phase: " + s.phase;
-
- if (s.winnerIndex >= 0 && s.winnerIndex < s.players.size()) {
- Player winner = s.players.get(s.winnerIndex);
- text = "Winner: " + winner.getName();
+ if (s == null) {
+ tableText.setText("Phase: -");
+ return;
}
- tableText.setText(text);
+ String winnerName = resolveWinnerName(s);
+ if (winnerName != null && !winnerName.isBlank()) {
+ tableText.setText("Winner: " + winnerName);
+ return;
+ }
+
+ String firstPreflopInfo = resolveFirstPreflopInfo(s);
+ if (firstPreflopInfo != null) {
+ tableText.setText(firstPreflopInfo);
+ return;
+ }
+
+ String phaseText = formatPhaseText(s.phase);
+ StringBuilder text = new StringBuilder("Phase: ").append(phaseText);
+
+ tableText.setText(text.toString());
+ }
+
+ private String formatPhaseText(String phase) {
+ if (phase == null || phase.isBlank()) {
+ return "-";
+ }
+ return phase.toUpperCase().replace("_", " ");
+ }
+
+ /**
+ * 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.
+ */
+ private String resolveFirstPreflopInfo(GameState s) {
+ if (s == null || s.players == null || s.players.size() < 2) {
+ return null;
+ }
+
+ if (!"PREFLOP".equalsIgnoreCase(s.phase)) {
+ return null;
+ }
+
+ int firstIndex =
+ (s.players.size() == 2) ? s.dealer : (s.dealer + DEALER_OFFSET) % s.players.size();
+ if (s.activePlayer != firstIndex) {
+ return null;
+ }
+
+ if (!isInitialPreflopBlindLayout(s)) {
+ return null;
+ }
+
+ int callTarget = Math.max(0, s.currentBet);
+ if (callTarget <= 0) {
+ return null;
+ }
+
+ long raiseTarget = (long) callTarget * 2L;
+ int safeRaiseTarget =
+ raiseTarget > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) raiseTarget;
+ return String.format(FIRST_PREFLOP_INFO_TEMPLATE, callTarget, safeRaiseTarget);
+ }
+
+ /**
+ * Checks whether the preflop state still matches the initial blind layout.
+ *
+ * @param s The current game state.
+ * @return true if exactly one small blind and one big blind are present and all other players
+ * have not yet committed chips.
+ */
+ private boolean isInitialPreflopBlindLayout(GameState s) {
+ int sbCount = 0;
+ int bbCount = 0;
+ int zeroCount = 0;
+
+ for (Player player : s.players) {
+ if (player == null) {
+ continue;
+ }
+
+ int bet = Math.max(0, player.getBet());
+ if (bet == SMALL_BLIND) {
+ sbCount++;
+ } else if (bet == BIG_BLIND) {
+ bbCount++;
+ } else if (bet == NO_BET) {
+ zeroCount++;
+ }
+ }
+
+ int playerCount = s.players.size();
+ return playerCount >= 2 && sbCount == 1 && bbCount == 1 && zeroCount == playerCount - 2;
}
/**
@@ -644,8 +787,10 @@ public class CasinoGameController {
* @param players The list of players currently in the game, used to determine which players are
* opponents and update their display in the UI accordingly. If the list is null or empty,
* all opponent slots will be cleared.
+ * @param activePlayerIndex The index of the active player in the players list, used to
+ * determine whose turn it is and update the UI highlights to reflect that.
*/
- private void updatePlayers(List players) {
+ private void updatePlayers(List players, int activePlayerIndex) {
if (players == null || players.isEmpty()) {
clearOpponentSlots();
return;
@@ -671,6 +816,7 @@ public class CasinoGameController {
safeRefresh(player1Controller);
safeRefresh(player2Controller);
safeRefresh(player3Controller);
+ updateActiveTurnHighlights(players, activePlayerIndex);
}
/**
@@ -695,7 +841,7 @@ public class CasinoGameController {
}
PlayerId pid = p.getId();
- boolean isMe = myPlayerId != null && pid != null && pid.equals(myPlayerId);
+ boolean isMe = myPlayerId != null && myPlayerId.equals(pid);
if (isMe) {
meFound = true;
@@ -767,6 +913,318 @@ public class CasinoGameController {
safeRefresh(player1Controller);
safeRefresh(player2Controller);
safeRefresh(player3Controller);
+ clearActiveTurnHighlights();
+ setTaskbarTurnHighlight(false);
+ }
+
+ /** Clear all visual highlights in the UI that indicate the active player's turn. */
+ private void clearActiveTurnHighlights() {
+ if (player1Controller != null) {
+ player1Controller.setTurnHighlighted(false);
+ }
+
+ if (player2Controller != null) {
+ player2Controller.setTurnHighlighted(false);
+ }
+
+ if (player3Controller != null) {
+ player3Controller.setTurnHighlighted(false);
+ }
+
+ if (playerStatusController != null) {
+ playerStatusController.setTurnHighlighted(false);
+ }
+
+ setPlayerCardsTurnHighlighted(false);
+ }
+
+ /**
+ * Set the visual highlight state of the player's hole cards to indicate whether it is currently
+ * the player's turn in the game.
+ *
+ * @param highlighted true to highlight the player's hole cards, indicating that it is their
+ * turn, or false to remove the highlight when it is not their turn.
+ */
+ private void setPlayerCardsTurnHighlighted(boolean highlighted) {
+ if (playerCardsBox == null) {
+ return;
+ }
+
+ for (Node child : playerCardsBox.getChildren()) {
+ if (child instanceof ImageView) {
+ toggleStyleClass(child, ACTIVE_CARD_BOX_STYLE_CLASS, highlighted);
+ }
+ }
+ }
+
+ /**
+ * Update the visual highlights in the UI to indicate which player's turn it is based on the
+ * active.
+ *
+ * @param players The list of players currently in the game, used to identify the active player
+ * and update the turn highlights accordingly.
+ * @param activePlayerIndex The index of the active player in the players list, used to
+ * determine whose turn it is and update the UI highlights to reflect that.
+ */
+ private void updateActiveTurnHighlights(List players, int activePlayerIndex) {
+ clearActiveTurnHighlights();
+
+ Player activePlayer = getPlayerAtIndex(players, activePlayerIndex);
+ if (activePlayer == null || activePlayer.getId() == null) {
+ setTaskbarTurnHighlight(false);
+ return;
+ }
+
+ if (myPlayerId != null && myPlayerId.equals(activePlayer.getId())) {
+ setPlayerCardsTurnHighlighted(true);
+ setTaskbarTurnHighlight(true);
+ return;
+ }
+
+ setTaskbarTurnHighlight(false);
+
+ if (player1Controller != null && player1Controller.hasPlayer(activePlayer)) {
+ player1Controller.setTurnHighlighted(true);
+ return;
+ }
+
+ if (player2Controller != null && player2Controller.hasPlayer(activePlayer)) {
+ player2Controller.setTurnHighlighted(true);
+ return;
+ }
+
+ if (player3Controller != null && player3Controller.hasPlayer(activePlayer)) {
+ player3Controller.setTurnHighlighted(true);
+ }
+ }
+
+ /**
+ * Set the turn highlight state in the taskbar controller, which visually indicates whether it
+ * is the current player's turn in the game.
+ *
+ * @param highlighted true to highlight the turn in the taskbar, false to remove the highlight.
+ */
+ private void setTaskbarTurnHighlight(boolean highlighted) {
+ TaskbarController taskbarCtrl = resolveTaskbarController();
+ if (taskbarCtrl != null) {
+ taskbarCtrl.setTurnHighlighted(highlighted);
+ }
+ }
+
+ /**
+ * Retrieve the Player object at the specified index from the list of players.
+ *
+ * @param players The list of players from which to retrieve the player at the specified index.
+ * @param index The index of the player to retrieve from the list.
+ * @return The Player object at the specified index if it exists and is valid, or null if the
+ * index is out of bounds or if the players list is null or empty.
+ */
+ private Player getPlayerAtIndex(List players, int index) {
+ if (players == null || index < 0 || index >= players.size()) {
+ return null;
+ }
+
+ return players.get(index);
+ }
+
+ /**
+ * Resolve the winner of the hand based on the game state.
+ *
+ * @param s The current game state, which may contain information about the players, their
+ * states, and the winner index.
+ * @return The Player object representing the winner if it can be determined from the game
+ * state, or null if the winner cannot be resolved or if the game is still in progress.
+ */
+ private Player resolveWinner(GameState s) {
+ if (s == null || s.players == null || s.players.isEmpty()) {
+ return null;
+ }
+
+ if (s.winnerIndex >= 0 && s.winnerIndex < s.players.size()) {
+ return s.players.get(s.winnerIndex);
+ }
+
+ Player remaining = null;
+ int remainingCount = 0;
+ for (Player player : s.players) {
+ if (player == null) {
+ continue;
+ }
+ if (player.getState() == PlayerState.FOLDED) {
+ continue;
+ }
+ if (remainingCount > 0) {
+ return null;
+ }
+ remaining = player;
+ remainingCount++;
+ }
+
+ if (remaining != null && remainingCount == 1) {
+ return remaining;
+ }
+
+ return null;
+ }
+
+ /**
+ * Resolve the name of the winner based on the game state.
+ *
+ * @param s The current game state, which may contain information about the players, their
+ * states, and the winner index.
+ * @return The name of the winner if it can be determined from the game state, or null if the
+ * winner cannot be resolved or if the game is still in progress.
+ */
+ private String resolveWinnerName(GameState s) {
+ Player winner = resolveWinner(s);
+ if (winner != null) {
+ String name = winner.getName();
+ if (name != null && !name.isBlank()) {
+ return name;
+ }
+ }
+
+ if (isTerminalPhase(s != null ? s.phase : null)
+ && lastWinnerName != null
+ && !lastWinnerName.isBlank()) {
+ return lastWinnerName;
+ }
+
+ 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.
+ *
+ * @param s The current game state, which may be null or contain information about the phase,
+ * players, and winner index.
+ * @param winnerName The name of the winner, which may be null or blank if the winner is not yet
+ * determined or if the game is still in progress.
+ * @return true if the game is considered finished based on the provided state and winner
+ * information, false otherwise.
+ */
+ private boolean isGameFinishedState(GameState s, String winnerName) {
+ if (s == null) {
+ return false;
+ }
+
+ boolean winnerKnown = winnerName != null && !winnerName.isBlank();
+ if (!winnerKnown) {
+ return false;
+ }
+
+ if (isTerminalPhase(s.phase)) {
+ return true;
+ }
+
+ if (s.winnerIndex >= 0 && s.players != null && s.winnerIndex < s.players.size()) {
+ return true;
+ }
+
+ return hasSingleRemainingPlayer(s);
+ }
+
+ /**
+ * Determine if there is only one remaining active player in the game state.
+ *
+ * @param s The current game state, which may be null or contain a list of players.
+ * @return true if there is exactly one active player remaining (not folded), false otherwise.
+ */
+ private boolean hasSingleRemainingPlayer(GameState s) {
+ if (s == null || s.players == null || s.players.isEmpty()) {
+ return false;
+ }
+
+ int remaining = 0;
+ for (Player player : s.players) {
+ if (player == null || player.getState() == PlayerState.FOLDED) {
+ continue;
+ }
+ remaining++;
+ if (remaining > 1) {
+ return false;
+ }
+ }
+ return remaining == 1;
+ }
+
+ /**
+ * Determine if the current phase of the game is a terminal phase, such as "FINISHED" or
+ * "SHOWDOWN".
+ *
+ * @param phase The current phase of the game, which may be null or blank.
+ * @return true if the phase is considered terminal, indicating that the hand has ended and a
+ * winner can be declared, false otherwise.
+ */
+ private boolean isTerminalPhase(String phase) {
+ return "FINISHED".equalsIgnoreCase(phase) || "SHOWDOWN".equalsIgnoreCase(phase);
+ }
+
+ /** Finish the game UI loop by stopping the timeline that updates the UI. */
+ private void finishGameUiLoop() {
+ if (gameFinished) {
+ return;
+ }
+ gameFinished = true;
+ if (timeline != null) {
+ timeline.stop();
+ }
+ }
+
+ /**
+ * Toggle a CSS style class on a JavaFX Node based on the active state.
+ *
+ * @param node The JavaFX Node on which to toggle the style class.
+ * @param styleClass The name of the CSS style class to toggle.
+ * @param active A boolean indicating whether to add (true) or remove (false) the style class
+ * from the node.
+ */
+ private void toggleStyleClass(Node node, String styleClass, boolean active) {
+ if (node == null || styleClass == null || styleClass.isBlank()) {
+ return;
+ }
+
+ if (active) {
+ if (!node.getStyleClass().contains(styleClass)) {
+ node.getStyleClass().add(styleClass);
+ }
+ } else {
+ node.getStyleClass().remove(styleClass);
+ }
}
/**
@@ -1315,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/CasinoBrowserController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java
index 72b0730..6a360a3 100644
--- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java
+++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/CasinoBrowserController.java
@@ -4,17 +4,24 @@ import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.HashSet;
+import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
+import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
+import javafx.scene.control.MenuItem;
import javafx.scene.control.TextField;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
@@ -63,6 +70,11 @@ public class CasinoBrowserController {
private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class);
+ private static final ObservableList URL_SUGGESTIONS =
+ FXCollections.observableArrayList(TRUSTED_DOMAINS);
+
+ private static ContextMenu autoCompleteMenu = new ContextMenu();
+
private static final int LOGO_HEIGHT = 40;
private static final int CORNER_RADIUS = 40;
private static final int HBOX_SPACIN = 10;
@@ -74,12 +86,15 @@ public class CasinoBrowserController {
private static final int ALERT_HEIGHT = 300;
private static final String LOGO_PATH = "/images/logoinverted.png";
private static final String LOGO_PATH_MAIN = "/images/logo.png";
+ private static final int MAX_AUTOCOMPLETE_RESULTS = 5;
static {
CookieHandler.setDefault(COOKIE_MANAGER);
TRUSTED_DOMAINS.add("wikipedia.org");
+ TRUSTED_DOMAINS.add("youtube.com");
TRUSTED_DOMAINS.add("github.com");
+ TRUSTED_DOMAINS.add("search.brave.com");
TRUSTED_DOMAINS.add("oracle.com");
TRUSTED_DOMAINS.add("stackoverflow.com");
TRUSTED_DOMAINS.add("docs.oracle.com");
@@ -94,10 +109,63 @@ public class CasinoBrowserController {
TRUSTED_DOMAINS.add("metager.de");
TRUSTED_DOMAINS.add("unibas.ch");
TRUSTED_DOMAINS.add("mojeek.com");
- TRUSTED_DOMAINS.add("searx.be ");
+ TRUSTED_DOMAINS.add("searx.be");
TRUSTED_DOMAINS.add("startpage.com");
}
+ /** Aliases for common websites. */
+ private static final java.util.Map ALIASES =
+ Map.ofEntries(
+ Map.entry("wiki", "wikipedia.org"),
+ Map.entry("yt", "youtube.com"),
+ Map.entry("bra", "search.brave.com"),
+ Map.entry("bs", "search.brave.com"),
+ Map.entry("gh", "github.com"),
+ Map.entry("google", "google.com"),
+ Map.entry("duck", "duckduckgo.com"),
+ Map.entry("bing", "bing.com"),
+ Map.entry("meta", "metager.de"),
+ Map.entry("mojeek", "mojeek.com"),
+ Map.entry("searx", "searx.be"),
+ Map.entry("startpage", "startpage.com"),
+ Map.entry("stack", "stackoverflow.com"),
+ Map.entry("so", "stackoverflow.com"),
+ Map.entry("oracle", "oracle.com"),
+ Map.entry("docs", "docs.oracle.com"),
+ Map.entry("mdn", "developer.mozilla.org"),
+ Map.entry("maven", "maven.apache.org"),
+ Map.entry("gradle", "gradle.org"),
+ Map.entry("spring", "spring.io"),
+ Map.entry("jetbrains", "jetbrains.com"),
+ Map.entry("uni", "unibas.ch"));
+
+ /**
+ * Resolves user input into a valid URL.
+ *
+ * @param input User input from the URL field, which can be a full URL, a domain name, or an
+ * alias.
+ * @return A properly formatted URL string that can be loaded by the browser, or the original
+ * input if it cannot be resolved.
+ */
+ private static String resolveInputToUrl(String input) {
+ if (input == null || input.isBlank()) {
+ return input;
+ }
+
+ input = input.trim();
+
+ String alias = ALIASES.get(input.toLowerCase());
+ if (alias != null) {
+ return "https://" + alias;
+ }
+
+ if (input.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*")) {
+ return input;
+ }
+
+ return "https://" + input;
+ }
+
private static boolean javascriptEnabled = false;
/** Deletes all cookies stored during the current browser session. */
@@ -124,6 +192,15 @@ public class CasinoBrowserController {
WebView webView = new WebView();
WebEngine engine = webView.getEngine();
+ engine.locationProperty()
+ .addListener(
+ (obs, oldUrl, newUrl) -> {
+ if (isDownloadUrl(newUrl)) {
+ LOGGER.warn("Download navigation blocked: " + newUrl);
+ engine.getLoadWorker().cancel();
+ }
+ });
+
engine.setJavaScriptEnabled(false);
configurePopupBlocker(engine);
@@ -203,9 +280,9 @@ public class CasinoBrowserController {
engine.setCreatePopupHandler(
config -> {
Alert alert = new Alert(Alert.AlertType.WARNING);
- alert.setTitle("Popup blockiert");
+ alert.setTitle("Popup blocked");
alert.setHeaderText(null);
- alert.setContentText("Popup wurde aus Sicherheitsgründen blockiert.");
+ alert.setContentText("Casono Browser blocked a security threat.");
try {
var stream = CasinoBrowserController.class.getResourceAsStream(LOGO_PATH);
@@ -283,9 +360,90 @@ public class CasinoBrowserController {
urlField.getStyleClass().add("gray-input-field");
HBox.setHgrow(urlField, Priority.ALWAYS);
+ urlField.textProperty()
+ .addListener(
+ (obs, oldText, newText) -> {
+ if (newText == null || newText.isBlank()) {
+ autoCompleteMenu.hide();
+ return;
+ }
+
+ String input = newText.toLowerCase();
+
+ String aliasMatch = ALIASES.get(input);
+ if (aliasMatch != null) {
+ autoCompleteMenu.getItems().clear();
+
+ MenuItem item = new MenuItem(aliasMatch);
+ item.setOnAction(
+ e -> {
+ urlField.setText("https://" + aliasMatch);
+ autoCompleteMenu.hide();
+ });
+
+ autoCompleteMenu.getItems().add(item);
+ autoCompleteMenu.show(urlField, javafx.geometry.Side.BOTTOM, 0, 0);
+ return;
+ }
+
+ updateSuggestions(input, urlField);
+ });
+
+ urlField.focusedProperty()
+ .addListener(
+ (obs, oldVal, newVal) -> {
+ if (!newVal) {
+ autoCompleteMenu.hide();
+ }
+ });
+
return urlField;
}
+ /**
+ * Updates the autocomplete suggestions based on the current input in the URL field. Suggestions
+ * are filtered from the list of trusted domains and sorted to prioritize those that start with
+ * the input.
+ *
+ * @param input The current text input from the URL field, used to filter and sort suggestions.
+ * @param urlField The TextField for the URL input, used to position the autocomplete menu and
+ * update its content.
+ */
+ private static void updateSuggestions(String input, TextField urlField) {
+ autoCompleteMenu.getItems().clear();
+
+ URL_SUGGESTIONS.stream()
+ .filter(
+ domain ->
+ domain.toLowerCase().startsWith(input)
+ || domain.toLowerCase().contains(input))
+ .sorted(
+ (a, b) -> {
+ boolean aStarts = a.startsWith(input);
+ boolean bStarts = b.startsWith(input);
+ return Boolean.compare(!aStarts, !bStarts);
+ })
+ .limit(MAX_AUTOCOMPLETE_RESULTS)
+ .forEach(
+ domain -> {
+ MenuItem item = new MenuItem(domain);
+
+ item.setOnAction(
+ e -> {
+ urlField.setText("https://" + domain);
+ autoCompleteMenu.hide();
+ });
+
+ autoCompleteMenu.getItems().add(item);
+ });
+
+ if (!autoCompleteMenu.getItems().isEmpty()) {
+ autoCompleteMenu.show(urlField, javafx.geometry.Side.BOTTOM, 0, 0);
+ } else {
+ autoCompleteMenu.hide();
+ }
+ }
+
/**
* Security Label
*
@@ -305,7 +463,7 @@ public class CasinoBrowserController {
* @return Toggle button
*/
public static Button createJsToggle(WebEngine engine) {
- Button jsToggle = new Button("JS EINSCHALTEN");
+ Button jsToggle = new Button("JS TURN ON");
jsToggle.getStyleClass().add("red-button");
jsToggle.setOnAction(
@@ -319,7 +477,7 @@ public class CasinoBrowserController {
jsToggle.getStyleClass().add("yellow-button");
} else {
- jsToggle.setText("JS EINSCHALTEN");
+ jsToggle.setText("JS TURN ON");
jsToggle.getStyleClass().removeAll("yellow-button");
jsToggle.getStyleClass().add("red-button");
}
@@ -410,7 +568,16 @@ public class CasinoBrowserController {
*/
public static void configureUrlEvents(
WebEngine engine, TextField urlField, Label securityLabel) {
- urlField.setOnAction(e -> loadUrlSafely(engine, urlField.getText(), securityLabel));
+ urlField.setOnKeyPressed(
+ e -> {
+ if (e.getCode() == KeyCode.ENTER) {
+ if (!autoCompleteMenu.isShowing()) {
+ String resolved = resolveInputToUrl(urlField.getText());
+ urlField.setText(resolved);
+ loadUrlSafely(engine, resolved, securityLabel);
+ }
+ }
+ });
engine.locationProperty().addListener((obs, o, n) -> urlField.setText(n));
}
@@ -468,25 +635,54 @@ public class CasinoBrowserController {
*/
private static void loadUrlSafely(WebEngine engine, String url, Label securityLabel) {
try {
- if (!url.startsWith("http")) {
+ if (!url.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*")) {
url = "https://" + url;
}
URI uri = new URI(url);
+ String scheme = uri.getScheme();
- // HTTPS required
- if (!"https".equalsIgnoreCase(uri.getScheme())) {
+ String fullUrl = uri.toString();
+
+ if (isDownloadUrl(fullUrl)) {
+ securityLabel.setText("BLOCKED DOWNLOAD");
+
+ LOGGER.warn("Download blocked: " + fullUrl);
+ return;
+ }
+
+ if ("file".equalsIgnoreCase(scheme)) {
+ Path allowed =
+ Paths.get(
+ System.getProperty("user.dir"),
+ "documents",
+ "docs",
+ "game-engine",
+ "manual.html")
+ .normalize();
+
+ Path requested = Paths.get(uri).normalize();
+
+ if (requested.equals(allowed)) {
+ securityLabel.setText("LOCAL OK");
+ engine.load(uri.toString());
+ } else {
+ securityLabel.setText("BLOCKED");
+ }
+ return;
+ }
+
+ if (!"https".equalsIgnoreCase(scheme)) {
securityLabel.setText("BLOCKED");
return;
}
String host = uri.getHost();
- if (host == null) {
+ if (host == null || host.isBlank()) {
securityLabel.setText("ERROR");
return;
}
- // Secure Domain Check
boolean trusted =
TRUSTED_DOMAINS.stream()
.anyMatch(domain -> host.equals(domain) || host.endsWith("." + domain));
@@ -500,9 +696,11 @@ public class CasinoBrowserController {
} else {
securityLabel.setText("SAFE");
}
+
engine.load(uri.toString());
+
} catch (Exception e) {
- securityLabel.setText("ERROR");
+ securityLabel.setText("INVALID");
}
}
@@ -519,7 +717,7 @@ public class CasinoBrowserController {
alert.setHeaderText("This website is unknown");
String content =
host
- + "\n\nThis site has not been verified by the Casono browser.\n"
+ + "\n\nThis site has not been verified by the Casono Browser.\n"
+ "Do you still want to open it?";
alert.setContentText(content);
@@ -545,4 +743,18 @@ public class CasinoBrowserController {
return true;
}
+
+ /**
+ * Checks if a given URL points to a downloadable file based on its extension.
+ *
+ * @param url the URL to check
+ * @return true if the URL is likely a download link, false otherwise
+ */
+ private static boolean isDownloadUrl(String url) {
+ if (url == null) {
+ return false;
+ }
+
+ return url.matches(".*\\.(exe|zip|dmg|msi|apk|jar|pdf)(\\?.*)?$");
+ }
}
diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java
index cfa2aad..48738c2 100644
--- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java
+++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/client/ui/gameui/gameuicomponents/PlayerStatusController.java
@@ -2,12 +2,15 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
+import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
+import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
+import javafx.scene.layout.VBox;
/**
* Controller for displaying a player's status in the poker game, including their name, chip count
@@ -21,11 +24,16 @@ public class PlayerStatusController {
@FXML private Label playerMoney;
@FXML private ImageView dealerIcon;
@FXML private Pane parent;
+ @FXML private VBox playerStatusBox;
+ @FXML private HBox statusInnerBoxTop;
+ @FXML private HBox statusInnerBoxBottom;
private Image dealerImage;
private Player player;
- private static final String DEALER_IMAGE_PATH = "/images/chip-dealer-blue-3.png";
+ private boolean turnHighlighted;
+ private static final String DEALER_IMAGE_PATH = "/images/chip-dealer-blue-5.png";
private static final double DEALER_ICON_X_FACTOR = 0.8;
+ private static final String TURN_HIGHLIGHT_STYLE_CLASS = "player-status-active-turn";
/** Initialize the controller, load dealer image, and set up bindings. */
@FXML
@@ -42,8 +50,7 @@ public class PlayerStatusController {
}
} catch (Exception e) {
- LOGGER.severe("Error: loading dealer image:");
- e.printStackTrace();
+ LOGGER.log(Level.SEVERE, "Error loading dealer image", e);
}
dealerIcon.layoutXProperty().bind(parent.widthProperty().multiply(DEALER_ICON_X_FACTOR));
@@ -57,13 +64,20 @@ public class PlayerStatusController {
*/
public void setPlayer(Player player) {
this.player = player;
+ if (player == null) {
+ turnHighlighted = false;
+ }
refresh();
+ updateTurnHighlightStyle();
}
/** Refresh UI safely */
public void refresh() {
if (player == null) {
+ playerName.setText("-");
+ playerMoney.setText("0 $");
+ dealerIcon.setVisible(false);
return;
}
@@ -84,6 +98,33 @@ public class PlayerStatusController {
}
}
+ /**
+ * Highlight or unhighlight this player slot as the currently active turn.
+ *
+ * @param highlighted true if this slot should be highlighted, false otherwise.
+ */
+ public void setTurnHighlighted(boolean highlighted) {
+ this.turnHighlighted = highlighted;
+ updateTurnHighlightStyle();
+ }
+
+ /**
+ * Check whether the controller is currently bound to the provided player.
+ *
+ * @param candidate the player to compare against.
+ * @return true if both represent the same player.
+ */
+ public boolean hasPlayer(Player candidate) {
+ if (player == null
+ || candidate == null
+ || player.getId() == null
+ || candidate.getId() == null) {
+ return false;
+ }
+
+ return player.getId().equals(candidate.getId());
+ }
+
/**
* External quick update
*
@@ -107,4 +148,26 @@ public class PlayerStatusController {
dealerIcon.setImage(dealerImage);
}
}
+
+ /**
+ * Update the visual style of the player status box to indicate whether it's currently this
+ * player's turn.
+ */
+ private void updateTurnHighlightStyle() {
+ if (statusInnerBoxTop == null || statusInnerBoxBottom == null) {
+ return;
+ }
+
+ if (turnHighlighted) {
+ if (!statusInnerBoxTop.getStyleClass().contains(TURN_HIGHLIGHT_STYLE_CLASS)) {
+ statusInnerBoxTop.getStyleClass().add(TURN_HIGHLIGHT_STYLE_CLASS);
+ }
+ if (!statusInnerBoxBottom.getStyleClass().contains(TURN_HIGHLIGHT_STYLE_CLASS)) {
+ statusInnerBoxBottom.getStyleClass().add(TURN_HIGHLIGHT_STYLE_CLASS);
+ }
+ } else {
+ statusInnerBoxTop.getStyleClass().remove(TURN_HIGHLIGHT_STYLE_CLASS);
+ statusInnerBoxBottom.getStyleClass().remove(TURN_HIGHLIGHT_STYLE_CLASS);
+ }
+ }
}
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 ca3dadd..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
@@ -8,11 +8,22 @@ import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
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;
+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;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
@@ -59,8 +70,16 @@ public class TaskbarController {
private static final String STYLE_RED_INPUT = "red-input-field";
private static final double SCALE_NORMAL = 1.0;
private static final int DEALER_OFFSET = 3;
+ private static final String LOGO_PATH = "/images/logoinverted.png";
+ private static final String LOGO_PATH_MAIN = "/images/logo.png";
+ 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() {
@@ -213,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.
@@ -220,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.
*
@@ -248,6 +359,7 @@ public class TaskbarController {
}
this.lastState = state;
+
if (state.currentBet > 0) {
lastReferenceBet = state.currentBet;
}
@@ -269,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();
}
@@ -287,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);
@@ -308,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);
@@ -353,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;
}
/**
@@ -431,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);
}
/**
@@ -473,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.
*
@@ -482,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;
@@ -618,57 +818,31 @@ public class TaskbarController {
}
GameState state = ensureLatestStateForAction(actionType.name().toLowerCase());
- if (state == null) {
- return;
- }
-
- if (isHandFinished(state)) {
- LOGGER.info("Action {} ignored: hand is already finished", actionType);
- update(state, myPlayerId);
+ if (state == null || isHandFinished(state)) {
+ if (state != null) {
+ LOGGER.info("Action {} ignored: hand finished", actionType);
+ update(state, myPlayerId);
+ }
return;
}
Player me = findCurrentPlayer(state);
if (me == null) {
- LOGGER.error("Action {} blocked: player missing in state", actionType);
+ LOGGER.error("Action {} blocked: player missing", actionType);
return;
}
- int callTarget = effectiveCallTarget(state);
- int targetBet;
- if (actionType == ActionType.CALL_BUTTON) {
- targetBet = callTarget;
- } else if (actionType == ActionType.RAISE_BUTTON) {
- targetBet = safeDouble(callTarget);
- } else {
- Integer fromInput = parseInputTarget(taskbarInput.getText());
- if (fromInput == null) {
- return;
- }
- targetBet = fromInput;
+ Integer targetBet = resolveTargetBet(actionType, state);
+ if (targetBet == null) {
+ return;
}
ValidationResult validation = validateTarget(state, me, targetBet);
- if (!validation.valid) {
- refreshBetInputUi();
+ if (!handleValidation(validation, actionType)) {
return;
}
- if (validation.warning) {
- showWarningDialog("WARNING", validation.message);
- }
-
- if (state.currentBet <= 0) {
- gameService.bet(targetBet);
- LOGGER.info("Player BET (target={})", targetBet);
- } else if (targetBet == callTarget) {
- gameService.call();
- LOGGER.info("Player CALL (target={})", targetBet);
- } else {
- int raiseBy = Math.max(0, targetBet - state.currentBet);
- gameService.raise(raiseBy);
- LOGGER.info("Player RAISE by {} (target={})", raiseBy, targetBet);
- }
+ executeAction(state, targetBet, actionType);
if (targetBet > 0) {
lastReferenceBet = targetBet;
@@ -678,6 +852,148 @@ public class TaskbarController {
refreshGame();
}
+ /**
+ * Resolves the target bet amount based on the type of action being submitted and the current
+ * game state.
+ *
+ * @param actionType The type of action being submitted, which determines how the target bet is
+ * calculated.
+ * @param state The current GameState object representing the state of the game, which is used
+ * to determine the effective call target and to validate the input for the bet amount.
+ * @return An Integer representing the resolved target bet amount for the action being
+ * submitted, or null if the input is invalid or cannot be resolved based on the action type
+ * and game state.
+ */
+ private Integer resolveTargetBet(ActionType actionType, GameState state) {
+ int callTarget = effectiveCallTarget(state);
+
+ if (actionType == ActionType.CALL_BUTTON) {
+ return callTarget;
+ }
+
+ if (actionType == ActionType.RAISE_BUTTON) {
+ Integer input = parseInputTarget(taskbarInput.getText());
+ return input != null ? input : callTarget + BIG_BLIND;
+ }
+
+ return parseInputTarget(taskbarInput.getText());
+ }
+
+ /**
+ * Handles the validation result of a proposed action, including displaying warnings and
+ * refreshing the UI if the action is blocked or cancelled.
+ *
+ * @param validation The ValidationResult object representing the outcome of validating the
+ * proposed action, which includes whether the action is valid, if it triggers a warning,
+ * and any associated messages.
+ * @param actionType The type of action being processed, used for logging purposes to indicate
+ * which action is being validated and potentially blocked or cancelled.
+ * @return A boolean value indicating whether the action is valid and can proceed (true) or if
+ * it is blocked or cancelled due to validation failure or user cancellation after a warning
+ * (false).
+ */
+ private boolean handleValidation(ValidationResult validation, ActionType actionType) {
+ if (!validation.valid) {
+ refreshBetInputUi();
+ return false;
+ }
+
+ if (validation.warning) {
+ boolean confirmed = showWarningDialog("WARNING", validation.message);
+ if (!confirmed) {
+ LOGGER.info("Action {} cancelled after warning", actionType);
+ refreshBetInputUi();
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Executes the player action by sending the appropriate command to the GameService based on the
+ * target bet and the current game state.
+ *
+ * @param state The current GameState object representing the state of the game, which includes
+ * information about the current bet and the player's status, used to determine how to
+ * execute the action based on the target bet.
+ * @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, 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 {}", phase, targetBet);
+ announceLobbyAction("BET " + targetBet);
+
+ return;
+ }
+
+ if (targetBet == callTarget) {
+
+ int amount = Math.max(0, callTarget - alreadyInvested);
+
+ gameService.call();
+
+ LOGGER.info("[{}] CALL +{} (total to {})", phase, amount, callTarget);
+ announceLobbyAction("CALL " + amount);
+
+ } else {
+
+ int raiseBy = Math.max(0, targetBet - callTarget);
+
+ gameService.raise(raiseBy);
+
+ 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());
+ }
+ }
+
/**
* Ensures that the latest game state is available for processing a player action. If the last
* known state is null, it attempts to fetch the current state from the GameService.
@@ -691,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;
}
@@ -737,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();
}
@@ -760,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();
}
@@ -849,12 +1198,50 @@ public class TaskbarController {
* @param content The string representing the content of the warning message, which is displayed
* in the body of the dialog.
*/
- private void showWarningDialog(String title, String content) {
+ private boolean showWarningDialog(String title, String content) {
Alert alert = new Alert(Alert.AlertType.WARNING);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(content);
- alert.showAndWait();
+ ButtonType proceedButton = new ButtonType("CONTINUE");
+ ButtonType cancelButton = new ButtonType("CANCEL", ButtonBar.ButtonData.CANCEL_CLOSE);
+ alert.getButtonTypes().setAll(proceedButton, cancelButton);
+ applyAlertBranding(alert);
+ Optional result = alert.showAndWait();
+ return result.isPresent() && result.get() == proceedButton;
+ }
+
+ /** Applies Casono icon and logo to alert dialogs when resources are available. */
+ private void applyAlertBranding(Alert alert) {
+ if (alert == null) {
+ return;
+ }
+
+ try (var iconStream = TaskbarController.class.getResourceAsStream(LOGO_PATH);
+ var mainLogoStream = TaskbarController.class.getResourceAsStream(LOGO_PATH_MAIN)) {
+ if (iconStream != null) {
+ Image icon = new Image(iconStream);
+ if (!icon.isError()
+ && alert.getDialogPane() != null
+ && alert.getDialogPane().getScene() != null
+ && alert.getDialogPane().getScene().getWindow()
+ instanceof javafx.stage.Stage stage) {
+ stage.getIcons().add(icon);
+ }
+ }
+
+ if (mainLogoStream != null) {
+ Image mainLogo = new Image(mainLogoStream);
+ if (!mainLogo.isError()) {
+ ImageView logoView = new ImageView(mainLogo);
+ logoView.setFitHeight(ALERT_LOGO_HEIGHT);
+ logoView.setPreserveRatio(true);
+ alert.setGraphic(logoView);
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.warn("Could not load alert logo resources: {}", e.getMessage());
+ }
}
/**
@@ -865,6 +1252,9 @@ public class TaskbarController {
*/
@FXML
private void onTaskbarPressed(MouseEvent event) {
+ if (taskbar == null || event == null) {
+ return;
+ }
xOffset = event.getSceneX() - taskbar.getLayoutX();
yOffset = event.getSceneY() - taskbar.getLayoutY();
}
@@ -873,17 +1263,25 @@ 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
private void onTaskbarDragged(MouseEvent event) {
- taskbar.setLayoutX(event.getSceneX() - xOffset);
- taskbar.setLayoutY(event.getSceneY() - yOffset);
+ if (taskbar == null || event == null || taskbar.getScene() == null) {
+ return;
+ }
taskbar.setScaleX(TASKBAR_SCALE);
taskbar.setScaleY(TASKBAR_SCALE);
+
+ double targetX = event.getSceneX() - xOffset;
+ double targetY = event.getSceneY() - yOffset;
+
+ double maxX = Math.max(0, taskbar.getScene().getWidth() - scaledNodeWidth());
+ double maxY = Math.max(0, taskbar.getScene().getHeight() - scaledNodeHeight());
+
+ taskbar.setLayoutX(clamp(targetX, 0, maxX));
+ taskbar.setLayoutY(clamp(targetY, 0, maxY));
}
/**
@@ -894,8 +1292,62 @@ public class TaskbarController {
*/
@FXML
private void onTaskbarReleased(MouseEvent event) {
+ if (taskbar == null) {
+ return;
+ }
taskbar.setScaleX(SCALE_NORMAL);
taskbar.setScaleY(SCALE_NORMAL);
+
+ if (taskbar.getScene() == null) {
+ return;
+ }
+
+ double maxX = Math.max(0, taskbar.getScene().getWidth() - scaledNodeWidth());
+ double maxY = Math.max(0, taskbar.getScene().getHeight() - scaledNodeHeight());
+
+ taskbar.setLayoutX(clamp(taskbar.getLayoutX(), 0, maxX));
+ taskbar.setLayoutY(clamp(taskbar.getLayoutY(), 0, maxY));
+ }
+
+ /**
+ * Calculates the scaled width of the taskbar node based on its current bounds and scale factor.
+ *
+ * @return The scaled width of the taskbar node, ensuring it is non-negative and accounts for
+ * the current scale applied to the node.
+ */
+ private double scaledNodeWidth() {
+ double width = taskbar.getBoundsInLocal().getWidth();
+ if (width <= 0) {
+ width = taskbar.prefWidth(-1);
+ }
+ return Math.max(0, width * taskbar.getScaleX());
+ }
+
+ /**
+ * Calculates the scaled height of the taskbar node based on its current bounds and scale
+ * factor.
+ *
+ * @return The scaled height of the taskbar node, ensuring it is non-negative and accounts for
+ * the current scale applied to the node.
+ */
+ private double scaledNodeHeight() {
+ double height = taskbar.getBoundsInLocal().getHeight();
+ if (height <= 0) {
+ height = taskbar.prefHeight(-1);
+ }
+ return Math.max(0, height * taskbar.getScaleY());
+ }
+
+ /**
+ * Clamps a value between a minimum and maximum bound.
+ *
+ * @param value the value to clamp
+ * @param min the lower bound
+ * @param max the upper bound
+ * @return the clamped value in the range [min, max]
+ */
+ private double clamp(double value, double min, double max) {
+ return Math.max(min, Math.min(value, max));
}
/**
@@ -928,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. */
@@ -949,6 +1401,7 @@ public class TaskbarController {
gameService.fold();
LOGGER.info("Player FOLD");
+ announceLobbyAction("FOLD");
refreshGame();
}
@@ -1062,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) {
@@ -1074,12 +1530,24 @@ public class TaskbarController {
/**
* Opens the integrated Casono web browser.
*
- *
TODO: Replace the start URL with the official project website (e.g., Tips & Tricks page)
- * once the content for strategies and support is available.
+ *