Merge branch 'refs/heads/main' into feat/chat-visual-improvement

This commit is contained in:
Mathis Ginkel
2026-04-26 21:46:52 +02:00
21 changed files with 2206 additions and 120 deletions
+11
View File
@@ -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.
+3 -1
View File
@@ -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
@@ -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 `
<h${level} id="${id}" class="cm-h${level}">
${title}
</h${level}>
`;
});
md = md.replace(/```([\s\S]*?)```/g, (_, code) => {
return `<pre class="cm-code"><code>${escapeHtml(code)}</code></pre>`;
});
md = md.replace(/!\[(.*?)\]\((.*?)\)/gim,
`<img alt="$1" src="$2" class="cm-img">`
);
md = md.replace(/\[(.*?)\]\((.*?)\)/gim,
`<a href="$2" target="_blank" class="cm-link">$1</a>`
);
md = md.replace(/`(.*?)`/gim,
`<code class="cm-inline">$1</code>`
);
md = md.replace(/\*\*(.*?)\*\*/gim, "<b>$1</b>");
md = md.replace(/\*(.*?)\*/gim, "<i>$1</i>");
md = md.replace(/^\s*-\s(.+)$/gim, "<li>$1</li>");
md = wrapLists(md);
md = md
.replace(/\n{2,}/g, "</p><p>")
.replace(/^(?!<h|<ul|<pre|<li|<\/)(.+)$/gim, "<p>$1</p>");
return buildToc(toc) + md;
}
};
function buildToc(items) {
if (!items.length) return "";
let html = `<div class="cm-toc">`;
items.forEach(i => {
html += `
<div class="cm-toc-item level-${i.level}">
<a href="#${i.id}">${i.title}</a>
</div>
`;
});
html += `</div>`;
return html;
}
function wrapLists(html) {
return html.replace(/(<li>.*?<\/li>)/gs, "<ul>$1</ul>");
}
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
window.marked = marked;
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Markdown Viewer</title>
<script src="casono-markdown-render-engine.js"></script>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background-color: #0d9e3b;
color: #ffffff;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
}
#content {
padding: 40px;
max-width: 900px;
margin: auto;
}
h1, h2, h3 {
color: #ffffff;
text-shadow: 2px 2px 0 #000;
}
p, li {
color: #ffffff;
font-size: 16px;
}
a {
color: gold;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
background: rgba(0,0,0,0.4);
padding: 2px 6px;
border-radius: 5px;
}
hr {
border: 1px solid #5c3d10;
}
.cm-toc {
margin-bottom: 25px;
}
.cm-toc-title {
font-weight: bold;
margin-bottom: 8px;
}
.cm-toc-item {
margin: 3px 0;
}
.cm-toc-item a {
color: gold;
text-decoration: none;
}
.cm-toc-item a:hover {
text-decoration: underline;
}
.level-2 { margin-left: 12px; opacity: 0.95; }
.level-3 { margin-left: 24px; opacity: 0.85; }
.cm-h1 {
font-size: 28px;
margin-top: 20px;
text-shadow: 2px 2px 0 #000;
}
.cm-h2 {
font-size: 22px;
margin-top: 18px;
}
.cm-h3 {
font-size: 18px;
margin-top: 14px;
}
.cm-img {
max-width: 600px;
width: 100%;
display: block;
margin: 10px 0;
border-radius: 6px;
}
/* CODE */
.cm-code {
background: rgba(0,0,0,0.35);
padding: 10px;
border-radius: 6px;
overflow-x: auto;
}
.cm-inline {
background: rgba(0,0,0,0.4);
padding: 2px 5px;
border-radius: 4px;
}
</style>
</head>
<body>
<div id="content"></div>
<script>
window.addEventListener("DOMContentLoaded", () => {
const markdown = `
# Casono Manual
Hinweis: Eine vereinfachte Einführung finden Sie in der Datei [Casono Rules Easy Description](casono-rules-easy-description.md).
Dieses Dokument beschreibt den Ablauf eines Pokerspiels anhand eines Beispiels.
## 1. Spielübersicht
Texas Holdem ist ein strategisches Kartenspiel für mehrere Spieler.
Ziel ist es, den Pot (alle gesetzten Chips) zu gewinnen, entweder durch:
- die beste Kartenkombination am Ende der Runde
- oder indem alle anderen Spieler vorher aussteigen (Fold)
### Grundregeln
- Jeder Spieler erhält 2 verdeckte Karten (Hole Cards)
- Es werden 5 Gemeinschaftskarten offen in der Mitte ausgelegt
- Jeder Spieler bildet die beste 5-Karten-Kombination aus:
- eigenen Karten
- und Gemeinschaftskarten
- Zu Spielbeginn erhält jeder Spieler ein Startgeld von 20000 Chips ($)
## 2. Sitzposition & Dealer-Button
Der sogenannte Dealer-Button bestimmt die Positionen am Tisch:
- Er zeigt an, wer als „Geber“ (Dealer) fungiert
- Die Positionen rotieren im Uhrzeigersinn nach jeder Runde
Die Position ist entscheidend, da sie bestimmt:
- die Reihenfolge der Aktionen
- wer die Blinds setzen muss
## 3. Blinds (Pflichteinsätze)
Vor jeder Runde werden zwei verpflichtende Einsätze geleistet:
- **Small Blind** (kleiner Blind): 100 Chips gesetzt vom Spieler links neben dem Dealer
- **Big Blind** (großer Blind): 200 Chips - gesetzt vom Spieler zwei Plätze links vom Dealer
Diese Einsätze sorgen dafür, dass:
- ein Startpot entsteht
- jede Runde aktiv gespielt wird
## 4. Spielablauf im Detail
### 4.1 Preflop (erste Setzrunde)
Nach dem Austeilen der Karten beginnt die erste Setzrunde.
Der Spieler links vom Big Blind eröffnet die Runde.
Jeder Spieler hat folgende Optionen:
- **Fold** Karten ablegen und aussteigen
- **Call** Einsatz mitgehen
- **Raise** Einsatz erhöhen
### 4.2 Flop (3 Gemeinschaftskarten)
- Drei Karten werden offen auf den Tisch gelegt
- Eine neue Setzrunde beginnt
- Die Setzrunde beginnt jetzt immer beim ersten aktiven Spieler links vom Dealer (im Uhrzeigersinn).
Ab diesem Zeitpunkt können alle Spieler ihre Strategie anhand zusätzlicher Informationen anpassen.
### 4.3 Turn (4. Gemeinschaftskarte)
- Die vierte Karte wird aufgedeckt
- Eine weitere Setzrunde folgt
Die Einsätze werden oft höher, da sich stärkere Hände entwickeln.
### 4.4 River (5. Gemeinschaftskarte)
- Die letzte Karte wird aufgedeckt
- Letzte Setzrunde
Dies ist die finale Entscheidungsphase:
- Maximierung des Gewinns
- oder Minimierung von Verlusten
## 5. Showdown (Kartenvergleich)
Wenn nach der letzten Setzrunde mindestens zwei Spieler verbleiben:
- Alle verbleibenden Spieler decken ihre Karten auf
- Die **beste 5-Karten-Kombination gewinnt**
Wichtig:
- Die Karten „sprechen für sich“ die beste Hand zählt unabhängig von Ansagen
## 6. Poker-Handrangfolge
Die Stärke der Hände ist eindeutig festgelegt (von schwach nach stark):
1. High Card (höchste Einzelkarte)
2. One Pair (ein Paar)
3. Two Pair (zwei Paare)
4. Three of a Kind (Drilling)
5. Straight (Straße)
6. Flush (Farbe)
7. Full House
8. Four of a Kind (Vierling)
9. Straight Flush
10. Royal Flush
Je höher die Kombination, desto stärker die Hand.
## 7. Wichtige Grundprinzipien
### Reihenfolge beachten
Spieler müssen immer der Reihe nach handeln.
### Klare Aktionen
Alle Aktionen müssen eindeutig sein:
- Einsätze klar ansagen oder eindeutig setzen
### Ein Spieler eine Hand
- Spieler dürfen ihre Karten nicht teilen oder gemeinsam spielen
### Fehlerhafte Einsätze
- Unklare oder falsche Einsätze können korrigiert werden, abhängig von der Spielsituation (nur wenn der Einsatz außerhalb der gültigen Grenzen liegt; zu hohe oder unzulässige Beträge werden blockiert und nicht automatisch korrigiert)
## 8. Strategische Einordnung
Texas Holdem ist kein reines Glücksspiel. Der Erfolg basiert auf:
- Wahrscheinlichkeiten (Mathematik)
- Einschätzung von Gegnern (Psychologie)
- Positionsspiel und Timing
`;
document.getElementById("content").innerHTML =
marked.parse(markdown);
document.querySelectorAll("#content a").forEach(link => {
link.addEventListener("click", (e) => {
const href = link.getAttribute("href");
if (href && href.endsWith(".md")) {
e.preventDefault();
alert("You cannot load an external MD file using `file:///` without a server.");
}
});
});
});
</script>
<noscript>
<div style="
padding: 40px;
font-family: Arial, sans-serif;
background-color: #0d9e3b;
color: #ffffff;
text-align: center;
">
<h2>JavaScript is disabled</h2>
<p>
The Casono Browser requires JavaScript for the <br>
Casono Markdown Render Engine to display content correctly.<br><br>
For security, JavaScript is disabled by default in the Casono Browser and should only be enabled when needed.
</p>
<p>
Please enable JavaScript in your Browser settings and reload the page.
</p>
<img src="images/activate-js.png" alt="JavaScript aktivieren Anleitung" style="max-width: 100%; margin-top: 20px; border-radius: 10px;">
</div>
</noscript>
</body>
</html>
@@ -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<Player> players = (s.players != null) ? s.players : List.of();
List<Card> community = (s.communityCards != null) ? s.communityCards : List.of();
List<Card> myCards = getMyCards(players);
java.util.List<String> 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<String> 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<Player> players) {
List<Card> community = (s.communityCards != null) ? s.communityCards : List.of();
List<Card> 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<Player> players) {
private void updatePlayers(List<Player> 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<Player> 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<Player> 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);
}
/**
@@ -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<String> 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<String, String> 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)(\\?.*)?$");
}
}
@@ -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);
}
}
}
@@ -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<String> 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<String> 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<TextFormatter.Change> 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<ButtonType> 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.
*
* <p>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.
*
* <p>TODO: Replace the start URL with the official project website (e.g., Tips & Tricks page)
* once the content for strategies and support is available.
* <p>once the content for strategies and support is available.
*/
@FXML
private void onBrowserButtonClick() {
CasinoBrowserController.open("wikipedia.org");
try {
Path path =
Paths.get(
System.getProperty("user.dir"),
"documents",
"docs",
"game-engine",
"manual.html");
CasinoBrowserController.open(path.toUri().toString());
} catch (Exception e) {
e.printStackTrace();
}
}
/** Opens the highscore popup window from the taskbar. */
@@ -1092,6 +1560,7 @@ public class TaskbarController {
alert.setTitle("Info");
alert.setHeaderText(null);
alert.setContentText("No active server connection for highscores.");
applyAlertBranding(alert);
alert.showAndWait();
return;
}
@@ -1124,4 +1593,23 @@ public class TaskbarController {
LOGGER.error("Could not open highscore window from taskbar: {}", e.getMessage());
}
}
/**
* Highlight or unhighlight the taskbar when it is the player's turn.
*
* @param highlighted true to highlight, false to remove highlight
*/
public void setTurnHighlighted(boolean highlighted) {
if (taskbar == null) {
return;
}
if (highlighted) {
if (!taskbar.getStyleClass().contains("player-active-turn")) {
taskbar.getStyleClass().add("player-active-turn");
}
} else {
taskbar.getStyleClass().remove("player-active-turn");
}
}
}
@@ -10,9 +10,16 @@ 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.protocol.response.ErrorResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
/** Handler for GET_GAME_STATE: returns pot, phase, community cards and per-player info. */
public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
private static final Logger LOGGER = Logger.getLogger(GetGameStateHandler.class.getName());
private static final int FINISHED_CLEANUP_DELAY_SECONDS = 2;
private final LobbyManager lobbyManager;
private final UserRegistry userRegistry;
@@ -73,11 +80,12 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
return;
}
if (game.getState().getPhase() == GamePhase.FINISHED) {
cleanupLobby(lobby, lobbyId);
}
responseDispatcher.dispatch(new GetGameStateResponse(request.getContext(), game, username));
if (game.getState().getPhase() == GamePhase.FINISHED) {
CompletableFuture.delayedExecutor(FINISHED_CLEANUP_DELAY_SECONDS, TimeUnit.SECONDS)
.execute(() -> cleanupLobby(lobby, lobbyId));
}
}
/**
@@ -85,15 +93,20 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
* when the game reaches FINISHED phase.
*/
private void cleanupLobby(Lobby lobby, LobbyId lobbyId) {
if (lobby == null || lobbyId == null) {
return;
}
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
LOGGER.log(
Level.WARNING,
"Failed to cleanup lobby " + lobbyId + " during FINISHED state",
e);
}
}
@@ -69,6 +69,10 @@ public class GetGameStateResponse extends SuccessResponse {
return;
}
if (!state.tryMarkWinnerPersistedForHand()) {
return;
}
Player winner = findPlayerByIndex(state, winnerIndex);
if (winner == null || winner.getId() == null || winner.getId().value() == null) {
return;
@@ -35,6 +35,7 @@ public class GameState {
private int currentPlayerIndex;
private boolean handActive;
private boolean allowOutOfTurn;
private boolean winnerPersistedForHand;
private GamePhase phase;
private int dealerIndex;
@@ -551,6 +552,7 @@ public class GameState {
*/
public void startNewHand() {
handActive = true;
resetWinnerPersistedForHand();
phase = GamePhase.PREFLOP;
pot = new Pot();
@@ -575,6 +577,24 @@ public class GameState {
setCurrentPlayerToPreflopFirstToAct();
}
/**
* Marks the winner as already persisted for the current hand.
*
* @return true exactly once per hand; false on all subsequent calls.
*/
public synchronized boolean tryMarkWinnerPersistedForHand() {
if (winnerPersistedForHand) {
return false;
}
winnerPersistedForHand = true;
return true;
}
/** Resets the winner persistence guard for a new hand. */
public synchronized void resetWinnerPersistedForHand() {
winnerPersistedForHand = false;
}
public void foldPlayer(PlayerId playerId) {
getPlayer(playerId).setFolded(true);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

@@ -48,7 +48,7 @@
}
.taskbar {
-fx-background-color: rgba(13, 158, 59);
-fx-background-color: rgb(13, 158, 59);
-fx-background-radius: 23;
-fx-border-radius: 20;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
@@ -56,13 +56,17 @@
-fx-padding: 10 25;
-fx-cursor: move;
-fx-effect: dropshadow(one-pass-box, rgba(0,0,0,0.8), 0, 0, 5, 5);
transition: all 0.2s ease-in-out;
}
.taskbar:hover {
-fx-border-color: #ffffff;
}
.taskbar.player-active-turn {
/* Active turn should only change the shadow to avoid flicker/transparency jumps. */
-fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);
}
.black-input-field {
-fx-background-color: #1a1a1a;
-fx-text-fill: #ffffff;
@@ -229,6 +233,13 @@
-fx-opacity: 1;
}
.player-card.player-cards-active-turn {
-fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);
}
.dealer-box.player-cards-active-turn {
-fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);
}
.chips-icon {
-fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3);
@@ -243,6 +254,10 @@
-fx-padding: 10;
}
/*.player-cards-box.player-cards-active-turn {*/
/* -fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);*/
/*}*/
.money-value {
-fx-text-fill: #ffffff;
-fx-font-size: 18px;
@@ -297,6 +312,14 @@
-fx-effect: dropshadow(one-pass-box, rgba(0, 0, 0, 0.8), 0, 0, 3, 3);
}
.status-inner-box-top.player-status-active-turn {
-fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);
}
.status-inner-box-bottom.player-status-active-turn {
-fx-effect: dropshadow(one-pass-box, rgb(184, 134, 11), 0, 0, 5, 5);
}
.status-label-small {
-fx-text-fill: #ffffff;
-fx-font-size: 18px;
@@ -369,3 +392,24 @@
-fx-font-weight: bold;
-fx-font-size: 16px;
}
.context-menu {
-fx-background-color: #333333;
-fx-text-fill: #ffffff;
-fx-font-family: "Courier New";
-fx-font-weight: bold;
-fx-background-radius: 8;
-fx-border-radius: 8;
-fx-border-color: #333333;
-fx-color-color: #cccccc;
-fx-border-width: 2;
-fx-padding: 5 12;
}
.context-menu .menu-item {
-fx-background-color: #333333;
}
.context-menu .menu-item:focused {
-fx-background-color: #333333;
}
@@ -67,7 +67,7 @@
</Label>
<Label fx:id="tableText"
text="Place your bet"
text="Welcome! The round is about to begin."
styleClass="table-title" />
</VBox>
@@ -93,6 +93,7 @@
<!-- Player Cards -->
<HBox fx:id="playerCardsBox"
styleClass="player-cards-box"
spacing="10"
alignment="CENTER"
StackPane.alignment="BOTTOM_CENTER">
@@ -6,8 +6,6 @@
<?import javafx.geometry.Insets?>
<!--
TODO: Main placeholder for player status & input.
This layout currently serves as a visual structure. As soon as the game engine and
server requests (API/requests) can be processed, the name, account balance
and status displays will be dynamically populated with real-time data from the server.
@@ -24,7 +22,8 @@ and status displays will be dynamically populated with real-time data from the s
<children>
<!-- NAME -->
<HBox styleClass="status-inner-box-top"
<HBox fx:id="statusInnerBoxTop"
styleClass="status-inner-box-top"
alignment="CENTER_LEFT"
prefHeight="40">
@@ -40,7 +39,8 @@ and status displays will be dynamically populated with real-time data from the s
<HBox prefHeight="5"/>
<!-- MONEY -->
<HBox styleClass="status-inner-box-bottom"
<HBox fx:id="statusInnerBoxBottom"
styleClass="status-inner-box-bottom"
alignment="CENTER_LEFT"
prefHeight="40">
@@ -39,19 +39,19 @@
styleClass="yellow-button"/>
<!-- Action buttons: Trigger Call -->
<Button text="Call"
<Button text="CALL"
fx:id="callButton"
onAction="#onInputPlayerCall"
styleClass="yellow-button"/>
<!-- Action buttons: Trigger Fold -->
<Button text="Fold"
<Button text="FOLD"
fx:id="foldButton"
onAction="#onInputPlayerFold"
styleClass="yellow-button"/>
<!-- Action buttons: Trigger Raise -->
<Button text="Raise"
<Button text="RAISE"
fx:id="raiseButton"
onAction="#onInputPlayerRaise"
styleClass="yellow-button"/>
@@ -0,0 +1,115 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.List;
import org.junit.jupiter.api.Test;
public class RequestParameterAccessorTest {
@Test
void testRequiredParameterPresent() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "VAL1"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals("VAL1", accessor.require("ARG1"));
}
@Test
void testMissingRequiredParameterThrows() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
MissingParameterException ex =
assertThrows(MissingParameterException.class, () -> accessor.require("ARG1"));
assertEquals("Required parameter with key 'ARG1' is missing.", ex.getMessage());
assertEquals("ARG1", ex.getParameterKey());
}
@Test
void testRequiredParameterSuccessfulParsing() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals(42, (int) accessor.require("ARG1", Integer::valueOf));
}
@Test
void testRequiredParameterInvalidParsingThrows() {
List<RequestParameter> parameters =
List.of(new RequestParameter("ARG1", "The answer is: 42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
ParameterParseException ex =
assertThrows(
ParameterParseException.class,
() -> accessor.require("ARG1", Integer::valueOf));
assertEquals("Error while parsing 'ARG1' with specified parser", ex.getMessage());
assertEquals("ARG1", ex.getParameterKey());
}
@Test
void testOptionalParameterPresent() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "VAL1"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals("VAL1", accessor.optional("ARG1", "DEFAULT"));
}
@Test
void testOptionalParameterMissing() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals("DEFAULT", accessor.optional("ARG1", "DEFAULT"));
}
@Test
void testOptionalParameterSuccessfulParsing() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals(42, (int) accessor.optional("ARG1", 7411, Integer::valueOf));
}
@Test
void testMissingOptionalParameterSuccessfulParsing() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals(7411, (int) accessor.optional("ARG1", 7411, Integer::valueOf));
}
@Test
void testOptionalParameterInvalidParsingThrows() {
List<RequestParameter> parameters =
List.of(new RequestParameter("ARG1", "The answer is: 42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
ParameterParseException ex =
assertThrows(
ParameterParseException.class,
() -> accessor.optional("ARG1", 7411, Integer::valueOf));
assertEquals("Error while parsing 'ARG1' with specified parser", ex.getMessage());
assertEquals("ARG1", ex.getParameterKey());
}
@Test
void testContainsExistingKey() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "VAL1"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertTrue(accessor.containsKey("ARG1"));
}
@Test
void testContainsMissingKey() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertFalse(accessor.containsKey("ARG1"));
}
}
@@ -0,0 +1,84 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
public class ResponseBodyBuilderTest {
@Test
void testSingleParameter() {
ResponseBody body = ResponseBody.builder().param("param1", "value1").build();
assertEquals(1, body.nodes().size());
ResponseParameter parameter = (ResponseParameter) body.nodes().get(0);
assertEquals("param1", parameter.key());
assertEquals("value1", parameter.value());
}
@Test
void testSingleBlock() {
ResponseBody body =
ResponseBody.builder()
.block("block1", block -> block.param("param1", "value1"))
.build();
assertEquals(1, body.nodes().size());
ResponseBlock block = (ResponseBlock) body.nodes().get(0);
assertEquals(1, block.children().size());
assertEquals("block1", block.tag());
ResponseParameter parameter = (ResponseParameter) block.children().get(0);
assertEquals("param1", parameter.key());
assertEquals("value1", parameter.value());
}
@Test
void testMultipleParameter() {
ResponseBody body =
ResponseBody.builder().param("param1", "value1").param("param2", "value2").build();
assertEquals(2, body.nodes().size());
ResponseParameter parameter;
parameter = (ResponseParameter) body.nodes().get(0);
assertEquals("param1", parameter.key());
assertEquals("value1", parameter.value());
parameter = (ResponseParameter) body.nodes().get(1);
assertEquals("param2", parameter.key());
assertEquals("value2", parameter.value());
}
@Test
void testMultipleBlocks() {
ResponseBody body =
ResponseBody.builder()
.block("block1", block -> block.param("param1", "value1"))
.block(
"block2",
block -> {
block.param("param2", "value2");
block.param("param3", "value3");
})
.build();
assertEquals(2, body.nodes().size());
ResponseBlock block;
ResponseParameter parameter;
block = (ResponseBlock) body.nodes().get(0);
assertEquals("block1", block.tag());
assertEquals(1, block.children().size());
parameter = (ResponseParameter) block.children().get(0);
assertEquals("param1", parameter.key());
assertEquals("value1", parameter.value());
block = (ResponseBlock) body.nodes().get(1);
assertEquals("block2", block.tag());
assertEquals(2, block.children().size());
parameter = (ResponseParameter) block.children().get(0);
assertEquals("param2", parameter.key());
assertEquals("value2", parameter.value());
}
}
@@ -0,0 +1,130 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher;
import static org.junit.jupiter.api.Assertions.assertEquals;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.PrimitiveResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.Response;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBlock;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseParameter;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
public class ResponseEncoderTest {
private class TestResponse extends Response {
public TestResponse(RequestContext context, ResponseBody body) {
super(context, body);
}
@Override
public String prefix() {
return "+OK";
}
}
@Test
void testEncodeParameterSimple() {
ResponseBody body = ResponseBody.builder().param("param", "value").build();
RequestContext ctx = new RequestContext(new SessionId(), 42);
Response response = new TestResponse(ctx, body);
PrimitiveResponse pr = ResponseEncoder.encode(response);
String expected = "+OK\n\tparam=value\nEND";
assertEquals(expected, pr.payload());
}
@Test
void testEncodeParameterMasking() {
ResponseBody body = ResponseBody.builder().param("msg", "It's a me, malario").build();
RequestContext ctx = new RequestContext(new SessionId(), 42);
Response response = new TestResponse(ctx, body);
PrimitiveResponse pr = ResponseEncoder.encode(response);
String expected = "+OK\n\tmsg='It\\'s a me, malario'\nEND";
assertEquals(expected, pr.payload());
}
@Test
void testEncodeNestedBlock() {
ResponseBody body =
ResponseBody.builder().block("TAG", b -> b.param("param", "key")).build();
RequestContext ctx = new RequestContext(new SessionId(UUID.randomUUID()), 42);
Response response = new TestResponse(ctx, body);
PrimitiveResponse pr = ResponseEncoder.encode(response);
String expected = "+OK\n\tTAG\n\t\tparam=key\n\tEND\nEND";
assertEquals(expected, pr.payload());
}
@Test
void testPrivateEncodeParameter()
throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
var param = new ResponseParameter("p", "v");
StringBuilder sb = new StringBuilder();
Method m =
ResponseEncoder.class.getDeclaredMethod(
"encodeParameter", ResponseParameter.class, StringBuilder.class, int.class);
m.setAccessible(true);
m.invoke(null, param, sb, 1);
assertEquals("\tp=v", sb.toString());
}
@Test
void testWordNoMask()
throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
Method m = ResponseEncoder.class.getDeclaredMethod("maskIfNeeded", String.class);
m.setAccessible(true);
String noMask = (String) m.invoke(null, "abc");
assertEquals("abc", noMask);
}
@Test
void testMaskString()
throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
Method m = ResponseEncoder.class.getDeclaredMethod("maskIfNeeded", String.class);
m.setAccessible(true);
String withSpace = (String) m.invoke(null, "a b");
assertEquals("'a b'", withSpace);
}
@Test
void testEscapeSingleQuote()
throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
Method m = ResponseEncoder.class.getDeclaredMethod("maskIfNeeded", String.class);
m.setAccessible(true);
String withSpace = (String) m.invoke(null, "a b");
assertEquals("'a b'", withSpace);
}
@Test
void testPrivateEncodeBlock()
throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
var child = new ResponseParameter("a", "b");
var block = new ResponseBlock("TAG", List.of(child));
StringBuilder sb = new StringBuilder();
Method m =
ResponseEncoder.class.getDeclaredMethod(
"encodeBlock", ResponseBlock.class, StringBuilder.class, int.class);
m.setAccessible(true);
m.invoke(null, block, sb, 1);
String expected = "\tTAG\n\t\ta=b\n\tEND";
assertEquals(expected, sb.toString());
}
}