Compare commits

...

88 Commits

Author SHA1 Message Date
Julian Kropff ce6fc1bf57 Add: Game UI test:
- Ensures FXML loads and controller initializes without errors.
2026-04-26 23:50:08 +02:00
Julian Kropff 05b3324cbd Add: Game UI Taskbar tests:
- input validation behavior
- UI state updates based on game state
- correct enabling/disabling of action buttons
2026-04-26 23:45:39 +02:00
Julian Kropff d6ad60d6a7 Fix: radical overhaul of Game UI tests to fix CI pipeline failures 2026-04-26 23:27:53 +02:00
Julian Kropff ddb3e221ac Fix: radical overhaul of Game UI tests to fix CI pipeline failures 2026-04-26 23:04:45 +02:00
Julian Kropff 52ad32a100 Fix: radical overhaul of Game UI tests to fix CI pipeline failures 2026-04-26 22:35:45 +02:00
Julian Kropff 822d9a0e1e Fix: Checkstyle 2026-04-26 21:57:14 +02:00
Julian Kropff 30927d471e Add: Game UI test: Ensures FXML loads and controller initializes without errors. 2026-04-26 21:42:47 +02:00
Julian Kropff 86ab82ebfd Add: Game UI Taskbar tests:
- input validation behavior
- UI state updates based on game state
- correct enabling/disabling of action buttons
2026-04-26 21:34:24 +02:00
Julian Kropff 6a06369d94 Fix: Checkstyle 2026-04-26 21:26:45 +02:00
Julian Kropff 1107542c54 Fix: Bug fix so that the 3000 cap does not apply to every Phase 2026-04-26 18:09:03 +02:00
Julian Kropff 2ad99d8732 Merge branch 'feat/game-ui-optimation' into 'main'
Feat: New Game UI Features and bug fixed

See merge request cs108-fs26/Gruppe-13!152
2026-04-26 15:52:40 +00:00
Julian Kropff d791e9b2d1 Merge branch 'main' into feat/game-ui-optimation 2026-04-26 17:43:37 +02:00
Lars Simon Winzer 3cd1e6925e Merge branch 'feat/128-unit-tests-for-requestparameteraccessor' into 'main'
Unit tests for RequestParameterAccessor

Closes #128

See merge request cs108-fs26/Gruppe-13!151
2026-04-26 17:19:30 +02:00
Lars Simon Winzer a7e4411cd0 Test: Add unit tests for RequestParameterAccessor 2026-04-26 17:13:11 +02:00
Julian Kropff bf979ab391 Fix: rename button names 2026-04-26 17:05:05 +02:00
Julian Kropff fb68ecc777 Feat: New Game UI Features:
- Call, Raise, Fold, Bet and Winner action are displayed in the Lobby Chat.
- More security features in the Game UI
- The Game UI is now even more Texas Hold'em friendly
2026-04-26 16:58:23 +02:00
Julian Kropff 8ca41c5ab4 Fix: Checkstyle 2026-04-26 16:51:43 +02:00
Julian Kropff 6422e6c924 Add: new welcome message in the Game UI 2026-04-26 16:21:26 +02:00
Julian Kropff 404bf6379c Add: the new Dealer Icon in PlayerStatusController 2026-04-26 16:20:26 +02:00
Julian Kropff d46bd8de7e Add: New Dealer Icon 2026-04-26 16:17:58 +02:00
Julian Kropff c55a9eace6 Add: Block file downloads in Casono Browser 2026-04-26 16:16:28 +02:00
Julian Kropff b3eaa64cd7 Add: Casono Logo to popups 2026-04-25 18:36:35 +02:00
Julian Kropff ab4f60a74f Add: hint for first player to act in preflop 2026-04-25 18:34:18 +02:00
Julian Kropff 4f9e9a0e9a Fix: Checkstyle 2026-04-25 16:26:54 +02:00
Julian Kropff c31f0048ee Style: fixes in FXML and CSS for Game UI 2026-04-25 15:02:02 +02:00
Julian Kropff 0f5ddfda0c Add: taskbar image for Casono manual 2026-04-25 14:58:51 +02:00
Julian Kropff 09f8ddf123 Add: Casono manual with integrated Markdown renderer 2026-04-25 14:57:05 +02:00
Julian Kropff c11201479b Add: visual indicator for current player in Game UI 2026-04-25 14:54:46 +02:00
Lars Simon Winzer 5ded42abe6 Merge branch 'fix/126-missing-key-is-not-detected-by-tokenclassifier' into 'main'
Detect missing keys while classifying tokens

Closes #126

See merge request cs108-fs26/Gruppe-13!150
2026-04-25 14:33:12 +02:00
Lars Simon Winzer 572b66889d Test: Add test for missing key before separator in TokenClassifier 2026-04-25 14:27:42 +02:00
Lars Simon Winzer 89fd6aef05 Fix: Detect missing key when validating separator 2026-04-25 14:26:22 +02:00
Julian Kropff 6e640f7fde Add: Casono manual via help button and taskbar dragging with screen boundary clamping and scaling 2026-04-25 14:19:47 +02:00
Julian Kropff 7a6c876017 Add: secure local HTML file loading in Casono Browser and website autocompletion 2026-04-25 14:05:31 +02:00
Lars Simon Winzer 22d742d614 Merge branch 'feat/4-create-unit-tests-for-server-side-tokenizer' into 'main'
Unit tests for Tokenizer and TokenClassifier

Closes #4

See merge request cs108-fs26/Gruppe-13!149
2026-04-25 13:58:52 +02:00
Julian Kropff 71d48d148b Fix: delay lobby cleanup to ensure winner is displayed on all Clients
Refs #125
2026-04-25 13:50:18 +02:00
Julian Kropff 5c0f3a7c86 Add: persistence guard for hand winner to prevent double saving
Refs #125
2026-04-25 13:43:18 +02:00
Lars Simon Winzer 2190fccd8f Style: Apply Spotless 2026-04-25 13:28:34 +02:00
Lars Simon Winzer 4bfb59f234 Test: Unit tests for TokenClassifier
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:28:10 +02:00
Lars Simon Winzer d49f18c3aa Test: Unit tests for Tokenizer 2026-04-25 10:44:09 +02:00
Jona Walpert d4a3ebb487 Merge branch 'refactor/124-lobby-cleanup-job' into 'main'
Refactor: Add LobbyCleanupJob for periodic expired lobby cleanup

Closes #124

See merge request cs108-fs26/Gruppe-13!148
2026-04-24 13:20:45 +00:00
Jona Walpert 3cabe06fb0 Merge branch 'main' into 'refactor/124-lobby-cleanup-job' to prevent merge conflicts 2026-04-24 15:15:29 +02:00
Jona Walpert 9023263b01 Refacor: Schedule LobbyCleanupJob instead of cleanup lambda
Refs #124

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:16:12 +02:00
Jona Walpert c04d4fca76 Refactor: Add LobbyCleanupJob for periodic expired lobby cleanup
- isolate failures per lobby during cleanup
- notify connected sessions about closed lobbies
Refs #124

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:14:59 +02:00
Mathis Ginkel d3d72bd5b4 Merge branch 'feat/chat-integration' into 'main'
Feat: Add chat box into the game screen

See merge request cs108-fs26/Gruppe-13!147
2026-04-24 12:14:16 +00:00
Mathis Ginkel 4a3c885b68 Style: Apply Spotless 2026-04-24 14:04:12 +02:00
Mathis Ginkel 571c616ce9 Merge remote-tracking branch 'origin/feat/chat-integration' into feat/chat-integration 2026-04-24 13:45:15 +02:00
Mathis Ginkel 01ecbb0497 Style: Apply Spotless 2026-04-24 13:45:02 +02:00
Julian Kropff 5e91cefabb Fix: whisper chat not working in game UI 2026-04-24 13:44:38 +02:00
Julian Kropff 0f4d048b76 Fix: Chat UI initialization improved in Game UI 2026-04-24 13:44:18 +02:00
Mathis Ginkel b1d099f102 Feat: Add load method to restore all chat message of a chat
Refs #107
2026-04-24 13:43:40 +02:00
Mathis Ginkel 40e558be18 Feat: Add chat box into the game screen
Refs #107
2026-04-24 13:40:44 +02:00
Mathis Ginkel 277b776fec Feat: Add chat box into game ui 2026-04-24 13:38:52 +02:00
Mathis Ginkel 88b797ba1d Feat: Add field ChatController to LobbyButtonGridManager 2026-04-24 13:37:12 +02:00
Mathis Ginkel 07c00275fd Merge remote-tracking branch 'origin/feat/chat-integration' into feat/chat-integration 2026-04-24 13:27:17 +02:00
Mathis Ginkel 1d4cf2d846 Style: Apply Spotless 2026-04-24 13:26:57 +02:00
Julian Kropff c2d0af03b7 Fix: whisper chat not working in game UI 2026-04-24 13:26:57 +02:00
Julian Kropff 48476a67f3 Fix: Chat UI initialization improved in Game UI 2026-04-24 13:26:57 +02:00
Julian Kropff 6471749133 Fix: correct layout layering in Game UI 2026-04-24 13:26:57 +02:00
Mathis Ginkel 826e58644a Feat: Add load method to restore all chat message of a chat
Refs #107
2026-04-24 13:26:57 +02:00
Mathis Ginkel 67a0f237e6 Feat: Forward ChatController to all instances where its needed
Refs #107
2026-04-24 13:26:57 +02:00
Mathis Ginkel 2a03571cd1 Feat: Add chat box into the game screen
Refs #107
2026-04-24 13:26:57 +02:00
Mathis Ginkel 7eafaba65a Feat: Add chat box into game ui 2026-04-24 13:26:57 +02:00
Mathis Ginkel 69fc00a3b2 Feat: Add field ChatController to LobbyButtonGridManager 2026-04-24 13:26:57 +02:00
Jona Walpert ca65655443 Merge branch 'refactor/121-duplicate-check-lobby-code' into 'main'
Refactor/121 duplicate check lobby code

See merge request cs108-fs26/Gruppe-13!146
2026-04-24 11:26:08 +00:00
Jona Walpert 488e8f524b Merge branch 'fix/123-server-assigned-username-send-to-client' into 'main'
Fix: Readd propagation of server assigned username. Login suername changed to...

Closes #123

See merge request cs108-fs26/Gruppe-13!145
2026-04-24 11:14:23 +00:00
Mathis Ginkel 9488ef41c3 Style: Apply Spotless 2026-04-24 11:46:32 +02:00
Julian Kropff 8bb0bfaf09 Fix: whisper chat not working in game UI 2026-04-24 11:16:34 +02:00
Julian Kropff 33998e4a88 Fix: Chat UI initialization improved in Game UI 2026-04-23 19:59:55 +02:00
Julian Kropff d094df16da Fix: correct layout layering in Game UI 2026-04-23 19:55:57 +02:00
Jona Walpert b9e45142c2 Refactor: changed duplicate lobyb existence checks to the new preexecution check 2026-04-23 15:17:30 +02:00
Jona Walpert 16c91c6aae Feat: Add lobby check to repalce duplicate code 2026-04-23 15:16:45 +02:00
Jona Walpert ea5f6a1083 Fix: Readd propagation of server assigned username. Login suername changed to optional instead of required 2026-04-23 14:46:24 +02:00
Mathis Ginkel c5f37392ef Feat: Add load method to restore all chat message of a chat
Refs #107
2026-04-23 12:06:43 +02:00
Mathis Ginkel 66ee8bec46 Feat: Forward ChatController to all instances where its needed
Refs #107
2026-04-23 12:06:43 +02:00
Mathis Ginkel 36348d0d93 Feat: Add chat box into the game screen
Refs #107
2026-04-23 12:06:43 +02:00
Mathis Ginkel 90b2f3bb3a Feat: Add chat box into game ui 2026-04-23 12:06:38 +02:00
Mathis Ginkel 1cddcf6657 Feat: Add field ChatController to LobbyButtonGridManager 2026-04-23 12:02:49 +02:00
Jona Walpert e358c930c5 Merge branch 'chore/122-fix-long-file-imports' into 'main'
Fix: Correct fxml imports and restore branch state

See merge request cs108-fs26/Gruppe-13!144
2026-04-22 16:33:47 +00:00
Jona Walpert 95fc53ec61 Fix: Wire correct controllers to fxml files
Co-authored-by: Copilot <copilot@github.com>
2026-04-22 18:29:54 +02:00
Jona Walpert 989dc95cef Fix: Final code restoration and import cleanup
Co-authored-by: Copilot <copilot@github.com>
2026-04-22 18:19:21 +02:00
Jona Walpert 2c343c72b3 Merge remote-tracking branch 'origin/main' into chore/122-fix-long-file-imports 2026-04-22 18:07:49 +02:00
Jona Walpert ffbfd2dee4 Fix: Correct fxml imports and restore branch state"
Co-authored-by: Copilot <copilot@github.com>
2026-04-22 17:51:35 +02:00
Jona Walpert 97808fe26d Merge branch 'fix/revert-failed-imports' into 'main'
Revert "Merge branch 'chore/122-fix-long-file-imports' into 'main'"

See merge request cs108-fs26/Gruppe-13!142
2026-04-22 15:43:11 +00:00
Jona Walpert 5b37dffa4c Revert "Merge branch 'chore/122-fix-long-file-imports' into 'main'"
This reverts commit 838ada39d5, reversing
changes made to 028c3adb0d.
2026-04-22 17:39:13 +02:00
Jona Walpert 838ada39d5 Merge branch 'chore/122-fix-long-file-imports' into 'main'
Chore: fix long line class paths in fxml files by using imports

Closes #122

See merge request cs108-fs26/Gruppe-13!141
2026-04-22 12:30:48 +00:00
Jona Walpert 4e675677c0 Chore: fix long line class paths on server side by using imports
Refs #122
2026-04-22 14:20:38 +02:00
Jona Walpert a629136fcb Chore: fix long line class paths on client side by using imports
Refs #122
2026-04-22 14:19:53 +02:00
Jona Walpert 30067bd69b Chore: fix long line class paths in fxml files by using imports
Refs #122
2026-04-22 14:18:50 +02:00
43 changed files with 2953 additions and 398 deletions
@@ -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>
@@ -11,7 +11,7 @@ import org.apache.logging.log4j.Logger;
* Entry point and bootstrap helper for the Casono client application.
*
* <p>Responsibilities: - Parse CLI args (host:port, optional username) - Store username centrally
* so UI can reuse it - Create a shared ClientService and do startup LOGIN (optional)
* so UI can reuse it - Create a shared ClientService and do startup LOGIN
*/
public class ClientApp {
@@ -40,7 +40,14 @@ public class ClientApp {
}
public static void updateSharedUsername(String username) {
setSharedUsername(username != null && !username.isBlank() ? username.trim() : null);
setSharedUsername(normalizeUsername(username));
}
private static String normalizeUsername(String username) {
if (username == null || username.isBlank()) {
return null;
}
return username.trim();
}
private static void setSharedUsername(String username) {
@@ -52,15 +59,14 @@ public class ClientApp {
String[] hostPort = parseHostAndPort(arg);
String host = hostPort[0];
int port = Integer.parseInt(hostPort[1]);
String normalizedUsername = normalizeUsername(username);
configureServerProperties(host, port);
setSharedUsername(username != null && !username.isBlank() ? username.trim() : null);
setSharedUsername(normalizedUsername);
if (getSharedUsername() != null) {
performStartupLogin(host, port, username);
}
performStartupLogin(host, port, normalizedUsername);
launchUI(arg, username);
launchUI(arg);
}
private static String[] parseHostAndPort(String arg) {
@@ -81,52 +87,33 @@ public class ClientApp {
try {
ClientService clientService = new ClientService(host, port);
setSharedClientService(clientService);
LobbyClient lobbyClient = new LobbyClient(clientService);
LoginResult res = lobbyClient.login(username);
java.util.concurrent.ExecutorService bg =
java.util.concurrent.Executors.newSingleThreadExecutor(
r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("casono-startup-login");
return t;
});
if (res != null && res.getUsername() != null && !res.getUsername().isBlank()) {
setSharedUsername(res.getUsername().trim());
}
bg.submit(
() -> {
try {
LobbyClient lobbyClient = new LobbyClient(clientService);
LoginResult res = lobbyClient.login(username);
if (res != null
&& res.getUsername() != null
&& !res.getUsername().isBlank()) {
setSharedUsername(res.getUsername().trim());
}
LOGGER.info(
"Assigned username='{}' id={}",
res != null ? res.getUsername() : null,
res != null ? res.getId() : null);
} catch (RuntimeException e) {
LOGGER.warn("Startup login failed: {}", e.getMessage());
} catch (Exception e) {
LOGGER.warn("Unexpected error during startup login", e);
} finally {
bg.shutdown();
}
});
String assignedUsername = null;
String assignedId = null;
if (res != null) {
assignedUsername = res.getUsername();
assignedId = res.getId();
}
LOGGER.info("Assigned username='{}' id={}", assignedUsername, assignedId);
} catch (RuntimeException e) {
LOGGER.warn(
"Could not establish initial connection for startup login: {}", e.getMessage());
}
}
private static void launchUI(String arg, String username) {
if (username != null && !username.isBlank()) {
Launcher.main(new String[] {arg, username});
} else {
private static void launchUI(String arg) {
String username = getSharedUsername();
if (username == null || username.isBlank()) {
Launcher.main(new String[] {arg});
return;
}
Launcher.main(new String[] {arg, username});
}
public static void main(String[] args) {
@@ -134,7 +121,10 @@ public class ClientApp {
throw new IllegalArgumentException("Address argument required: <host:port>");
}
String username = args.length > 1 && args[1] != null && !args[1].isBlank() ? args[1] : null;
String username = null;
if (args.length > 1) {
username = normalizeUsername(args[1]);
}
start(args[0], username);
}
@@ -4,8 +4,10 @@ import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ChatClient;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatBoxController;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatViewController;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -39,7 +41,11 @@ public class ChatController {
return chatBoxController;
}
private final ChatBoxController chatBoxController;
public void setChatBoxController(ChatBoxController chatBoxController) {
this.chatBoxController = chatBoxController;
}
private ChatBoxController chatBoxController;
private int lobbyId = -1;
private final Timer timer;
private final Consumer<List<String>> serverEventListener;
@@ -61,6 +67,12 @@ public class ChatController {
/** List of all users connected to the server, to safe them locally on the client */
private final List<String> localUserList;
public List<String> getLocalUserList() {
return this.localUserList;
}
public final Map<ChatController.ChatKey, ChatViewController> activeChatControllers;
private final Logger logger;
/**
@@ -78,6 +90,7 @@ public class ChatController {
this.chatBoxController = new ChatBoxController(username, this);
this.logger = LogManager.getLogger(ChatController.class);
this.serverEventListener = this::handleServerEvent;
this.activeChatControllers = new HashMap<>();
registerAsActiveController(clientService);
clientService.addEventListener(serverEventListener);
@@ -126,7 +139,7 @@ public class ChatController {
}
ChatModel lobbyChatModel = new ChatModel(ChatType.LOBBY, username, lobbyId, null);
chatModelMap.put(key, lobbyChatModel);
this.chatBoxController.addChatTab("Lobby", lobbyChatModel);
this.chatBoxController.addChatTab("LOBBY", lobbyChatModel, ChatType.LOBBY);
}
/**
@@ -81,11 +81,7 @@ public class LobbyClient {
throw new RuntimeException("No LOBBY_ID in response: " + lines);
}
/**
* Fetches the global highscores from the server.
*
* @return list of formatted highscore entries, newest entries at the end
*/
/** Fetches the global highscores from the server. */
public List<String> getHighscores() {
List<String> lines = client.processCommand("GET_HIGHSCORES");
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
@@ -139,7 +135,8 @@ public class LobbyClient {
* server
*/
public LoginResult login(String user) {
List<String> lines = client.processCommand("LOGIN USERNAME=" + user);
String command = buildLoginCommand(user);
List<String> lines = client.processCommand(command);
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
@@ -155,6 +152,13 @@ public class LobbyClient {
return new LoginResult(assigned, id);
}
private String buildLoginCommand(String username) {
if (username == null || username.isBlank()) {
return "LOGIN";
}
return "LOGIN USERNAME=" + username.trim();
}
/**
* Changes the username for the currently logged-in session.
*
@@ -199,7 +203,6 @@ public class LobbyClient {
String val = p.value();
switch (key) {
case "ID":
// If we were collecting a lobby, flush it
if (currentId != null) {
result.add(
new LobbyInfo(
@@ -3,6 +3,7 @@ package ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatType;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
@@ -27,7 +28,7 @@ public class ChatBoxController {
private String username;
private ChatController chatController;
private final ChatController chatController;
@FXML private VBox chatBox;
@@ -43,8 +44,6 @@ public class ChatBoxController {
@FXML private final Map<String, Tab> usernameTabMap;
private String ressource = "/ui-structure/components/chatui/chattab.fxml";
/**
* Constructor for the ChatBoxController, initializes the necessary fields and data structures
* for managing chat tabs and whisper chats.
@@ -73,11 +72,10 @@ public class ChatBoxController {
*/
@FXML
public void initialize() {
ChatModel globalChatModel = new ChatModel(ChatType.GLOBAL, username, -1, null);
chatController
.getChatModelMap()
.put(new ChatController.ChatKey(ChatType.GLOBAL), globalChatModel);
addChatTab("GLOBAL", globalChatModel);
ChatType global = ChatType.GLOBAL;
ChatModel globalChatModel = new ChatModel(global, username, -1, null);
chatController.getChatModelMap().put(new ChatController.ChatKey(global), globalChatModel);
addChatTab("GLOBAL", globalChatModel, global);
addWhisperChatButton.setOnAction(event -> addWhisperChatButton.show());
}
@@ -166,10 +164,11 @@ public class ChatBoxController {
public void addWhisperChat(String target, ChatModel chatModel) {
if (!activeWhisperChats.contains(target)) {
activeWhisperChats.add(target);
ChatType whisper = ChatType.WHISPER;
chatController
.getChatModelMap()
.put(new ChatController.ChatKey(ChatType.WHISPER, target), chatModel);
addChatTab(target, chatModel);
.put(new ChatController.ChatKey(whisper, target), chatModel);
addChatTab(target, chatModel, whisper);
} else {
chatTabPane.getSelectionModel().select(usernameTabMap.get(target));
}
@@ -184,7 +183,8 @@ public class ChatBoxController {
* @param chatModel The {@link ChatModel} containing the data and logic for this specific chat.
* @throws RuntimeException If the FXML resource for the chat tab cannot be loaded.
*/
public void addChatTab(String title, ChatModel chatModel) {
public void addChatTab(String title, ChatModel chatModel, ChatType chatType) {
String ressource = "/ui-structure/components/chatui/chattab.fxml";
URL resource = getClass().getResource(ressource);
FXMLLoader fxmlLoader = new FXMLLoader(resource);
runOnPlatformSynchronized(
@@ -193,6 +193,8 @@ public class ChatBoxController {
ChatViewController chatViewController =
new ChatViewController(
this.chatController, chatModel, this.username);
chatController.activeChatControllers.put(
new ChatController.ChatKey(chatType), chatViewController);
fxmlLoader.setController(chatViewController);
Node load = fxmlLoader.load();
VBox.setVgrow(load, Priority.ALWAYS);
@@ -239,4 +241,30 @@ public class ChatBoxController {
}
}
}
public void loadChats() {
Map<ChatController.ChatKey, ChatModel> chatModelMap = chatController.getChatModelMap();
ChatController.ChatKey key = new ChatController.ChatKey(ChatType.GLOBAL);
ChatModel global = chatModelMap.get(key);
ChatViewController globalController = chatController.activeChatControllers.get(key);
for (String user : chatController.getLocalUserList()) {
addWhisperUser(user);
}
for (Message msg : global.messages) {
globalController.showMessage(msg);
}
for (String user : chatController.getLocalUserList()) {
ChatController.ChatKey whisperUserKey =
new ChatController.ChatKey(ChatType.WHISPER, user);
if (chatModelMap.containsKey(whisperUserKey)) {
ChatModel whisperChatModel = chatModelMap.get(whisperUserKey);
addWhisperChat(user, whisperChatModel);
for (Message msg : whisperChatModel.messages) {
chatController.activeChatControllers.get(whisperUserKey).showMessage(msg);
}
}
}
}
}
@@ -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;
@@ -18,15 +21,13 @@ import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
/**
* Controller for the casino gaming area.
@@ -67,7 +68,9 @@ public class CasinoGameController {
private java.util.List<String> lastCommunityKeys = java.util.List.of();
private java.util.List<String> lastMyCardKeys = java.util.List.of();
private int lastPot = Integer.MIN_VALUE;
private ChatController chatController;
public static final double CHAT_CONTAINER_CONSTANT = 6.0;
private static final int TOTAL_SLOTS = 5;
private static final int PLAYER_SLOTS = 2;
private int pot = 0;
@@ -138,12 +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 ChatController chatController;
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() {
@@ -195,6 +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. */
@@ -206,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");
@@ -226,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();
@@ -239,7 +265,8 @@ public class CasinoGameController {
// empty display only (optional)
renderCommunityCards(List.of());
renderPlayerCards(List.of());
initializeChatIfPossible();
chatContainer.toFront();
}
/**
@@ -257,7 +284,7 @@ public class CasinoGameController {
this.chatUsername = username;
this.chatClientService = clientService;
this.chatLobbyId = lobbyId;
initializeChatIfPossible();
configureTaskbarLobbyActionAnnouncements();
}
/**
@@ -265,42 +292,32 @@ public class CasinoGameController {
* is available and the chat has not already been initialized.
*/
private void initializeChatIfPossible() {
if (chatInitialized || chatClientService == null || chatUsername == null) {
if (chatInitialized
|| chatClientService == null
|| chatUsername == null
|| chatController == null) {
return;
}
try {
chatController = new ChatController(chatUsername, chatClientService);
chatController.updateUsername(chatUsername);
URL resource = getClass().getResource("/ui-structure/components/chatui/chatbox.fxml");
FXMLLoader loader = new FXMLLoader(resource);
loader.setController(chatController.getChatBoxController());
Node chatNode = loader.load();
chatContainer.getChildren().setAll(chatNode);
AnchorPane.setTopAnchor(chatNode, CHAT_CONTAINER_CONSTANT);
AnchorPane.setBottomAnchor(chatNode, CHAT_CONTAINER_CONSTANT);
AnchorPane.setLeftAnchor(chatNode, 0.0);
AnchorPane.setRightAnchor(chatNode, CHAT_CONTAINER_CONSTANT);
Parent root = loader.load();
Stage chatStage = new Stage();
chatStage.setTitle("Casono");
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
chatStage.getIcons().add(new Image(iconPath));
Scene scene = new Scene(root);
chatStage.setScene(scene);
chatStage.setWidth(CHAT_WIDTH);
chatStage.setHeight(CHAT_HEIGHT);
chatStage.setOnCloseRequest(event -> chatInitialized = false);
chatStage.show();
chatController.getChatBoxController().loadChats();
chatInitialized = true;
if (chatLobbyId >= 0) {
chatController.setLobbyChat(chatLobbyId);
}
chatInitialized = true;
} catch (IOException e) {
LOGGER.warning("Could not initialize game chat UI: " + e.getMessage());
}
@@ -483,7 +500,7 @@ public class CasinoGameController {
*/
private void applyState(GameState s) {
if (s == null) {
if (s == null || gameFinished) {
return;
}
@@ -496,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);
@@ -539,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.
*
@@ -616,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;
}
/**
@@ -650,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;
@@ -677,6 +816,7 @@ public class CasinoGameController {
safeRefresh(player1Controller);
safeRefresh(player2Controller);
safeRefresh(player3Controller);
updateActiveTurnHighlights(players, activePlayerIndex);
}
/**
@@ -701,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;
@@ -773,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);
}
}
/**
@@ -1321,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);
}
/**
@@ -1,5 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
@@ -24,6 +26,7 @@ public class CasinoGameUI extends Application {
private static final Logger LOG = Logger.getLogger(CasinoGameUI.class.getName());
private static ClientService clientService;
private static ChatController chatController;
private static String username;
private static int lobbyId = -1;
@@ -55,6 +58,10 @@ public class CasinoGameUI extends Application {
CasinoGameUI.username = username;
}
public static void setChatController(ChatController chatController) {
CasinoGameUI.chatController = chatController;
}
/**
* Sets the lobby ID to be used by the application.
*
@@ -77,8 +84,7 @@ public class CasinoGameUI extends Application {
public void start(Stage stage) throws IOException {
if (clientService == null) {
clientService =
ch.unibas.dmi.dbis.cs108.casono.client.ClientApp.getSharedClientService();
clientService = ClientApp.getSharedClientService();
}
if (clientService == null) {
throw new IllegalStateException(
@@ -90,8 +96,7 @@ public class CasinoGameUI extends Application {
String effectiveUsername = normalize(username);
if (effectiveUsername == null) {
effectiveUsername =
normalize(ch.unibas.dmi.dbis.cs108.casono.client.ClientApp.getSharedUsername());
effectiveUsername = normalize(ClientApp.getSharedUsername());
}
if (effectiveUsername == null) {
@@ -105,7 +110,7 @@ public class CasinoGameUI extends Application {
+ "', injectedUsername='"
+ username
+ "', sharedUsername='"
+ ch.unibas.dmi.dbis.cs108.casono.client.ClientApp.getSharedUsername()
+ ClientApp.getSharedUsername()
+ "', hasClientService="
+ (clientService != null));
@@ -125,6 +130,8 @@ public class CasinoGameUI extends Application {
controller.setMyPlayerId(PlayerId.of(effectiveUsername));
controller.setChatContext(effectiveUsername, clientService, lobbyId);
controller.startChat(chatController);
Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT);
stage.setTitle("Casono");
@@ -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 && isFirstPreflopPlayer(state, me);
inputActionAllowed = false;
if (!isMyTurn || isOutOrFinished || state == null || me == null) {
setBetButtonVisible(false);
@@ -308,7 +433,7 @@ public class TaskbarController {
return;
}
if (isFirstPreflopPlayerInputOnly(state, me)) {
if (isFirstPreflopPlayer(state, me)) {
setActionEnabled(betButton, true);
setActionEnabled(callButton, false);
setActionEnabled(foldButton, false);
@@ -318,6 +443,16 @@ public class TaskbarController {
return;
}
if (isFirstPlayerOfPhase(state, me)) {
setActionEnabled(betButton, true);
setActionEnabled(callButton, false);
// setActionEnabled(foldButton, false);
setActionEnabled(raiseButton, false);
activateInputField(taskbarInput);
inputActionAllowed = true;
return;
}
int callTarget = effectiveCallTarget(state);
int raiseTarget = safeDouble(callTarget);
ValidationResult callValidation = validateTarget(state, me, callTarget);
@@ -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);
}
/**
@@ -449,7 +605,7 @@ public class TaskbarController {
* @return A boolean value indicating whether the current player is the first to act in the
* pre-flop phase with only the initial blind layout in place.
*/
private boolean isFirstPreflopPlayerInputOnly(GameState state, Player me) {
private boolean isFirstPreflopPlayer(GameState state, Player me) {
if (state == null || me == null || !isPreflop(state.phase) || state.players == null) {
return false;
}
@@ -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 (isFirstPreflopPlayer(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");
}
}
}
@@ -55,9 +55,9 @@ public class CasinomainuiController {
}
/**
* Initializes the UI components and sets default values. If a shared {@link
* ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService} exists (created at application
* start), the controller reuses it so the connection remains open and already-logged-in.
* Initializes the UI components and sets default values. If a shared {@link ClientService}
* exists (created at application start), the controller reuses it so the connection remains
* open and already-logged-in.
*/
@FXML
public void initialize() {
@@ -141,6 +141,7 @@ public class CasinomainuiController {
AnchorPane.setBottomAnchor(chatNode, 0.0);
AnchorPane.setLeftAnchor(chatNode, 0.0);
AnchorPane.setRightAnchor(chatNode, 0.0);
gridManager.setChatController(chatController);
} catch (IOException e) {
LOGGER.warn("Could not initialize lobby chat UI: {}", e.getMessage());
}
@@ -1,7 +1,10 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.ArrayList;
import java.util.Collections;
@@ -41,6 +44,8 @@ public class LobbyButtonGridManager {
private final LobbyButtonTranslationManager translationManager;
private final LobbyClient lobbyClient;
private ChatController chatController;
private final ConcurrentHashMap<String, Image> imageCache = new ConcurrentHashMap<>();
private final ExecutorService executor = Executors.newCachedThreadPool();
@@ -553,14 +558,11 @@ public class LobbyButtonGridManager {
try {
var cs = lobbyClient.getClientService();
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI
.setClientService(cs);
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI.setLobbyId(
lobbyId);
CasinoGameUI.setClientService(cs);
CasinoGameUI.setLobbyId(lobbyId);
CasinoGameUI.setChatController(chatController);
String username =
ch.unibas.dmi.dbis.cs108.casono.client.ClientApp
.getSharedUsername();
String username = ClientApp.getSharedUsername();
if (username == null || username.isBlank()) {
username =
"Guest-"
@@ -570,11 +572,9 @@ public class LobbyButtonGridManager {
.toString()
.substring(0, GUEST_ID_LENGTH);
}
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI.setUsername(
username);
CasinoGameUI.setUsername(username);
new ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI()
.start(gameStage);
new CasinoGameUI().start(gameStage);
} catch (Exception e) {
LOGGER.error("Game UI failed: {}", e.getMessage());
@@ -590,4 +590,8 @@ public class LobbyButtonGridManager {
public LobbyClient getLobbyClient() {
return lobbyClient;
}
public void setChatController(ChatController chatController) {
this.chatController = chatController;
}
}
@@ -63,6 +63,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyCleanupJob;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
@@ -73,11 +74,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandR
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionDisconnectJob;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
import java.time.Duration;
@@ -141,37 +138,12 @@ public class ServerApp {
userRegistry,
new CommandContext(lobbyManager, sessionManager));
// Periodic cleanup: remove empty lobbies older than 30s and notify affected
// users
scheduler.scheduleAtFixedRate(
() -> {
try {
var expired =
lobbyManager.findEmptyLobbiesOlderThan(
Duration.ofSeconds(LOBBY_EXPIRY_SECONDS));
for (var lid : expired) {
// remove lobby from manager first
lobbyManager.removeLobby(lid);
// broadcast LOBBY_CLOSED event to all connected sessions
// (requestId=0)
for (Session s : sessionManager.getAllSessions()) {
RequestContext ctx = new RequestContext(s.getId(), 0);
SuccessResponse ev =
new SuccessResponse(
ctx,
ResponseBody.builder()
.param("EVENT", "LOBBY_CLOSED")
.param("LOBBY_ID", lid.value())
.build()) {};
responseDispatcher.dispatch(ev);
}
}
} catch (Exception e) {
logger.warn("Lobby expiry job failed", e);
}
},
new LobbyCleanupJob(
lobbyManager,
sessionManager,
responseDispatcher,
Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)),
LOBBY_CLEANUP_INITIAL_DELAY_SECONDS,
LOBBY_CLEANUP_PERIOD_SECONDS,
TimeUnit.SECONDS);
@@ -0,0 +1,75 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.checks;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.checks.HandlerCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.Response;
import java.util.Optional;
/**
* Checks whether a target lobby exists for game action commands.
*
* <p>Supported commands are BET, CALL, FOLD and RAISE. If a request specifies a GAME_ID, the check
* validates that this lobby exists. Otherwise, it validates that the logged-in user is in a lobby.
*/
public class GameLobbyExistsCheck implements HandlerCheck {
private final UserRegistry userRegistry;
private final LobbyManager lobbyManager;
public GameLobbyExistsCheck(UserRegistry userRegistry, LobbyManager lobbyManager) {
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
}
@Override
public Optional<Response> check(Request request) {
Integer gameId = extractGameId(request);
User user = userRegistry.getBySessionId(request.getSessionId()).get();
Lobby lobby;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
} else {
lobby = lobbyManager.getLobbyByUsername(user.getName());
}
if (lobby != null) {
return Optional.empty();
}
if (gameId != null) {
return Optional.of(
new ErrorResponse(request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
}
return Optional.of(
new ErrorResponse(request.getContext(), "NOT_IN_LOBBY", "User not in a lobby"));
}
private Integer extractGameId(Request request) {
if (request instanceof PlayerBetRequest) {
return ((PlayerBetRequest) request).getGameId();
}
if (request instanceof PlayerCallRequest) {
return ((PlayerCallRequest) request).getGameId();
}
if (request instanceof PlayerFoldRequest) {
return ((PlayerFoldRequest) request).getGameId();
}
if (request instanceof PlayerRaiseRequest) {
return ((PlayerRaiseRequest) request).getGameId();
}
throw new IllegalStateException(
"GameLobbyExistsCheck is only supported for BET, CALL, FOLD and RAISE requests");
}
}
@@ -1,9 +1,13 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.GameLobbyExistsCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
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;
@@ -40,6 +44,8 @@ public class PlayerBetHandler extends CommandHandler<PlayerBetRequest> {
super(responseDispatcher);
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
addCheck(new UserLoggedInCheck(userRegistry));
addCheck(new GameLobbyExistsCheck(userRegistry, lobbyManager));
}
/**
@@ -58,8 +64,7 @@ public class PlayerBetHandler extends CommandHandler<PlayerBetRequest> {
return;
}
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt =
userRegistry.getBySessionId(request.getSessionId());
Optional<User> opt = userRegistry.getBySessionId(request.getSessionId());
if (opt.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in"));
@@ -69,22 +74,11 @@ public class PlayerBetHandler extends CommandHandler<PlayerBetRequest> {
String username = opt.get().getName();
Integer gameId = request.getGameId();
var lobby =
(gameId != null)
? lobbyManager.getLobby(LobbyId.of(gameId))
: lobbyManager.getLobbyByUsername(username);
if (lobby == null) {
if (gameId != null) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
} else {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "NOT_IN_LOBBY", "User not in a lobby"));
}
return;
Lobby lobby;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
} else {
lobby = lobbyManager.getLobbyByUsername(username);
}
GameController game = lobby.getGameController();
@@ -1,9 +1,13 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.GameLobbyExistsCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
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;
@@ -36,6 +40,8 @@ public class PlayerCallHandler extends CommandHandler<PlayerCallRequest> {
super(responseDispatcher);
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
addCheck(new UserLoggedInCheck(userRegistry));
addCheck(new GameLobbyExistsCheck(userRegistry, lobbyManager));
}
/**
@@ -46,8 +52,7 @@ public class PlayerCallHandler extends CommandHandler<PlayerCallRequest> {
*/
@Override
public void execute(PlayerCallRequest request) {
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt =
userRegistry.getBySessionId(request.getSessionId());
Optional<User> opt = userRegistry.getBySessionId(request.getSessionId());
if (opt.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in"));
@@ -57,22 +62,11 @@ public class PlayerCallHandler extends CommandHandler<PlayerCallRequest> {
String username = opt.get().getName();
Integer gameId = request.getGameId();
var lobby =
(gameId != null)
? lobbyManager.getLobby(LobbyId.of(gameId))
: lobbyManager.getLobbyByUsername(username);
if (lobby == null) {
if (gameId != null) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
} else {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "NOT_IN_LOBBY", "User not in a lobby"));
}
return;
Lobby lobby;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
} else {
lobby = lobbyManager.getLobbyByUsername(username);
}
GameController game = lobby.getGameController();
@@ -1,9 +1,13 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.GameLobbyExistsCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
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;
@@ -36,6 +40,8 @@ public class PlayerFoldHandler extends CommandHandler<PlayerFoldRequest> {
super(responseDispatcher);
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
addCheck(new UserLoggedInCheck(userRegistry));
addCheck(new GameLobbyExistsCheck(userRegistry, lobbyManager));
}
/**
@@ -46,8 +52,7 @@ public class PlayerFoldHandler extends CommandHandler<PlayerFoldRequest> {
*/
@Override
public void execute(PlayerFoldRequest request) {
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt =
userRegistry.getBySessionId(request.getSessionId());
Optional<User> opt = userRegistry.getBySessionId(request.getSessionId());
if (opt.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in"));
@@ -57,22 +62,11 @@ public class PlayerFoldHandler extends CommandHandler<PlayerFoldRequest> {
String username = opt.get().getName();
Integer gameId = request.getGameId();
var lobby =
(gameId != null)
? lobbyManager.getLobby(LobbyId.of(gameId))
: lobbyManager.getLobbyByUsername(username);
if (lobby == null) {
if (gameId != null) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
} else {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "NOT_IN_LOBBY", "User not in a lobby"));
}
return;
Lobby lobby;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
} else {
lobby = lobbyManager.getLobbyByUsername(username);
}
GameController game = lobby.getGameController();
@@ -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;
@@ -1,9 +1,13 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.GameLobbyExistsCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.app.checks.UserLoggedInCheck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.GameController;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
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;
@@ -40,6 +44,8 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> {
super(responseDispatcher);
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
addCheck(new UserLoggedInCheck(userRegistry));
addCheck(new GameLobbyExistsCheck(userRegistry, lobbyManager));
}
/**
@@ -60,8 +66,7 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> {
return;
}
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt =
userRegistry.getBySessionId(request.getSessionId());
Optional<User> opt = userRegistry.getBySessionId(request.getSessionId());
if (opt.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(request.getContext(), "NOT_LOGGED_IN", "User not logged in"));
@@ -71,22 +76,11 @@ public class PlayerRaiseHandler extends CommandHandler<PlayerRaiseRequest> {
String username = opt.get().getName();
Integer gameId = request.getGameId();
var lobby =
(gameId != null)
? lobbyManager.getLobby(LobbyId.of(gameId))
: lobbyManager.getLobbyByUsername(username);
if (lobby == null) {
if (gameId != null) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "LOBBY_NOT_FOUND", "Lobby not found"));
} else {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(), "NOT_IN_LOBBY", "User not in a lobby"));
}
return;
Lobby lobby;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
} else {
lobby = lobbyManager.getLobbyByUsername(username);
}
GameController game = lobby.getGameController();
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
@@ -21,8 +22,7 @@ public class GetLobbyListHandler extends CommandHandler<GetLobbyListRequest> {
@Override
public void execute(GetLobbyListRequest request) {
Collection<ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby> l =
lobbyManager.getAllLobbies();
Collection<Lobby> l = lobbyManager.getAllLobbies();
responseDispatcher.dispatch(new GetLobbyListResponse(request.getContext(), l));
}
}
@@ -11,6 +11,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
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;
@@ -38,8 +39,7 @@ public class StartGameHandler extends CommandHandler<StartGameRequest> {
@Override
public void execute(StartGameRequest request) {
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> opt =
userRegistry.getBySessionId(request.getSessionId());
Optional<User> opt = userRegistry.getBySessionId(request.getSessionId());
if (opt.isEmpty()) {
// Guard: UserLoggedInCheck should normally handle this
responseDispatcher.dispatch(
@@ -16,6 +16,6 @@ public class LoginParser implements CommandParser<LoginRequest> {
public LoginRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
return new LoginRequest(primitiveRequest.context(), accessor.require("USERNAME"));
return new LoginRequest(primitiveRequest.context(), accessor.optional("USERNAME", null));
}
}
@@ -5,6 +5,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User;
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.OkResponse;
@@ -40,8 +41,7 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
@Override
public void execute(SendMessageRequest request) {
Message message = request.getMessage();
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> senderUser =
userRegistry.getBySessionId(request.getSessionId());
Optional<User> senderUser = userRegistry.getBySessionId(request.getSessionId());
if (senderUser.isPresent()) {
message.sender = senderUser.get().getName();
}
@@ -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);
}
@@ -0,0 +1,100 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
import java.time.Duration;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/** Periodically removes expired empty lobbies and notifies connected sessions. */
public class LobbyCleanupJob implements Runnable {
private static final Logger LOGGER = LogManager.getLogger(LobbyCleanupJob.class);
private final LobbyManager lobbyManager;
private final SessionManager sessionManager;
private final ResponseDispatcher responseDispatcher;
private final Duration expiryThreshold;
/**
* Creates a new cleanup job for expired empty lobbies.
*
* @param lobbyManager manager used to find and remove expired lobbies
* @param sessionManager manager used to resolve connected sessions
* @param responseDispatcher dispatcher used to notify clients about closed lobbies
* @param expiryThreshold age threshold that marks an empty lobby as expired
*/
public LobbyCleanupJob(
LobbyManager lobbyManager,
SessionManager sessionManager,
ResponseDispatcher responseDispatcher,
Duration expiryThreshold) {
this.lobbyManager = lobbyManager;
this.sessionManager = sessionManager;
this.responseDispatcher = responseDispatcher;
this.expiryThreshold = expiryThreshold;
}
/**
* Runs one cleanup cycle.
*
* <p>The method first fetches all expired empty lobbies. Each lobby is then processed
* independently so that a failure for one lobby does not stop the remaining cleanups.
*/
@Override
public void run() {
LOGGER.debug("Job started.");
try {
List<LobbyId> expired;
try {
expired = lobbyManager.findEmptyLobbiesOlderThan(expiryThreshold);
} catch (Exception e) {
LOGGER.error("Lobby expiry job failed: could not fetch expired lobbies", e);
return;
}
for (LobbyId lobbyId : expired) {
try {
lobbyManager.removeLobby(lobbyId);
broadcastLobbyClosed(lobbyId);
} catch (RuntimeException e) {
LOGGER.warn("Failed to process expired lobby {}", lobbyId.value(), e);
}
}
} finally {
LOGGER.debug("Job finished.");
}
}
/**
* Broadcasts a lobby-closed event to all currently connected sessions.
*
* @param lobbyId id of the lobby that was closed
*/
private void broadcastLobbyClosed(LobbyId lobbyId) {
for (Session session : sessionManager.getAllSessions()) {
try {
RequestContext ctx = new RequestContext(session.getId(), 0);
SuccessResponse event =
new SuccessResponse(
ctx,
ResponseBody.builder()
.param("EVENT", "LOBBY_CLOSED")
.param("LOBBY_ID", lobbyId.value())
.build()) {};
responseDispatcher.dispatch(event);
} catch (RuntimeException e) {
LOGGER.warn(
"Failed to notify session {} about closed lobby {}",
session.getId().value(),
lobbyId.value(),
e);
}
}
}
}
@@ -48,7 +48,7 @@ public class TokenClassifier {
}
private static void validateSeparator(List<RawToken> rawTokens, int index) {
boolean missingKey = index == 0 || rawTokens.get(index - 1).type() != RawTokenType.WORD;
boolean missingKey = index <= 1 || rawTokens.get(index - 1).type() != RawTokenType.WORD;
boolean missingValue =
index + 1 >= rawTokens.size()
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;
}
@@ -5,6 +5,7 @@
<?import javafx.geometry.Insets?>
<?import javafx.scene.image.*?>
<!-- Main container: Links the UI to the CasinoGameController and loads Casinogameui.css -->
<AnchorPane xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
@@ -66,7 +67,7 @@
</Label>
<Label fx:id="tableText"
text="Place your bet"
text="Welcome! The round is about to begin."
styleClass="table-title" />
</VBox>
@@ -75,12 +76,14 @@
<HBox fx:id="communityCardsBox"
styleClass="community-cards-box"
alignment="CENTER"
GridPane.columnIndex="2"
spacing="10"/>
<!-- Pot Box -->
<HBox fx:id="potBox"
alignment="CENTER"
spacing="4"
GridPane.columnIndex="2"
maxWidth="Infinity"
maxHeight="Infinity"/>
@@ -90,6 +93,7 @@
<!-- Player Cards -->
<HBox fx:id="playerCardsBox"
styleClass="player-cards-box"
spacing="10"
alignment="CENTER"
StackPane.alignment="BOTTOM_CENTER">
@@ -98,6 +102,7 @@
styleClass="dealer-box"
fitWidth="100"
fitHeight="100"
GridPane.columnIndex="2"
preserveRatio="true"
visible="false"/>
@@ -116,56 +121,48 @@
<!-- RIGHT SIDE (Chat Box) -->
<AnchorPane fx:id="chatContainer" GridPane.columnIndex="2" />
<HBox AnchorPane.topAnchor="45"
AnchorPane.leftAnchor="0"
AnchorPane.rightAnchor="0"
alignment="CENTER"
spacing="300">
<VBox HBox.hgrow="ALWAYS"
alignment="TOP_LEFT"
translateX="50"
translateY="50">
<fx:include fx:id="player1"
source="gameuicomponents/Playerstatus.fxml"/>
</VBox>
<VBox HBox.hgrow="ALWAYS"
alignment="TOP_CENTER"
translateY="20">
<fx:include fx:id="player2"
source="gameuicomponents/Playerstatus.fxml"/>
</VBox>
<VBox HBox.hgrow="ALWAYS"
alignment="TOP_RIGHT"
translateX="-50"
translateY="50">
<fx:include fx:id="player3"
source="gameuicomponents/Playerstatus.fxml"/>
</VBox>
</HBox>
</children>
</GridPane>
<!-- movable taskbar -->
<AnchorPane>
<fx:include fx:id="taskbarInclude" source="gameuicomponents/Taskbar.fxml"/>
<fx:include fx:id="taskbarInclude" source="gameuicomponents/Taskbar.fxml" GridPane.columnIndex="1"/>
</AnchorPane>
<!-- Opponent Status 1: One of three panels displaying the opponent's icon, name, and account balance -->
<GridPane AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="50">
<columnConstraints>
<ColumnConstraints percentWidth="5"/>
<ColumnConstraints percentWidth="16"/>
</columnConstraints>
<children>
<fx:include fx:id="player1" source="gameuicomponents/Playerstatus.fxml" GridPane.columnIndex="1"/>
</children>
</GridPane>
<!-- Opponent Status 2: One of three panels displaying the opponent's icon, name, and account balance -->
<GridPane AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="20">
<columnConstraints>
<ColumnConstraints percentWidth="30"/>
<ColumnConstraints percentWidth="16"/>
</columnConstraints>
<children>
<fx:include fx:id="player2" source="gameuicomponents/Playerstatus.fxml" GridPane.columnIndex="1"/>
</children>
</GridPane>
<!-- Opponent Status 3: One of three panels that displays the opponent's icon, name, and account balance -->
<GridPane AnchorPane.leftAnchor="0.0"
AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="50">
<columnConstraints>
<ColumnConstraints percentWidth="55"/>
<ColumnConstraints percentWidth="16"/>
</columnConstraints>
<children>
<fx:include fx:id="player3" source="gameuicomponents/Playerstatus.fxml" GridPane.columnIndex="1"/>
</children>
</GridPane>
</AnchorPane>
@@ -7,6 +7,7 @@
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<VBox fx:id="highscoreRoot"
xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
@@ -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.
@@ -16,7 +14,7 @@ and status displays will be dynamically populated with real-time data from the s
<VBox fx:id="playerStatusBox"
xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController"
styleClass="player-status-pane"
prefWidth="200"
spacing="5">
@@ -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">
@@ -3,11 +3,10 @@
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>
<!-- movable taskbar -->
<HBox xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController"
fx:id="taskbar"
styleClass="taskbar"
spacing="15"
@@ -40,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"/>
@@ -4,6 +4,7 @@
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<!--
Dieses Fenster wird später mit den Serveranfragen verknüpft,
um Nachrichten von Mitspielern und Systemmeldungen in Echtzeit anzuzeigen.
@@ -19,7 +20,7 @@
<VBox xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatViewController"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatViewController"
alignment="CENTER"
stylesheets="@chatui.css">
@@ -0,0 +1,56 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController;
import java.net.URL;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/** Smoke test for Taskbar UI. Ensures FXML loads and controller initializes without errors. */
class CasinoGameControllerUITest {
private static boolean fxStarted = false;
/** Initializes JavaFX once before all tests. */
@BeforeAll
static void startJavaFX() throws Exception {
if (!fxStarted) {
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(
() -> {
Platform.setImplicitExit(false);
latch.countDown();
});
} catch (IllegalStateException ignored) {
latch.countDown();
}
assertTrue(latch.await(10, TimeUnit.SECONDS), "JavaFX did not start");
fxStarted = true;
}
}
/** Ensures FXML loads and controller initializes without errors. */
@Test
void taskbarLoadsWithoutErrors() throws Exception {
URL url = getClass().getResource("/ui-structure/gameuicomponents/Taskbar.fxml");
assertNotNull(url);
FXMLLoader loader = new FXMLLoader(url);
Parent root = loader.load();
TaskbarController controller = loader.getController();
assertNotNull(root);
assertNotNull(controller);
}
}
@@ -0,0 +1,175 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
import static org.junit.jupiter.api.Assertions.*;
import ch.unibas.dmi.dbis.cs108.casono.client.game.*;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.TextField;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/**
* TODO: Refactor UI tests to avoid reflection and test behavior via public APIs or real UI actions
* (preferably using TestFX) for better maintainability and robustness.
*
* <p>These tests verify: - input validation behavior - UI state updates based on game state -
* correct enabling/disabling of action buttons
*/
class TaskbarControllerInputValidationTest {
private static boolean fxStarted = false;
/** Initializes JavaFX once before all tests. */
@BeforeAll
static void initJavaFX() throws Exception {
if (fxStarted) {
return;
}
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(
() -> {
Platform.setImplicitExit(false);
latch.countDown();
});
} catch (IllegalStateException ignored) {
latch.countDown();
}
latch.await(10, TimeUnit.SECONDS);
fxStarted = true;
}
/** Ensures invalid input values do not enable the BET button. */
@Test
void invalidInputShouldBeRejected() throws Exception {
Fixture f = load();
String[] invalidInputs = {"", "abc", "-1", "999999999999"};
for (String input : invalidInputs) {
setText(f.input, input);
invokePrivate(f.controller, "refreshBetInputUi");
Button bet = get(f.controller, "betButton", Button.class);
assertFalse(bet.isVisible(), "Invalid input must not enable BET button: " + input);
}
}
/** Ensures valid input enables the BET button. */
@Test
void validInputShouldEnableBet() throws Exception {
Fixture f = load();
GameState state = createGameState();
setField(f.controller, "myPlayerId", PlayerId.of("me"));
setField(f.controller, "lastState", state);
setField(f.controller, "inputActionAllowed", true);
f.controller.update(state, PlayerId.of("me"));
setText(f.input, "200");
invokePrivate(f.controller, "refreshBetInputUi");
Button bet = get(f.controller, "betButton", Button.class);
assertTrue(bet.isVisible(), "BET button should be enabled for valid input");
}
/** Ensures all UI actions are disabled when the game is finished. */
@Test
void finishedStateShouldDisableUi() throws Exception {
Fixture f = load();
GameState state = createGameState();
state.phase = "FINISHED";
f.controller.update(state, PlayerId.of("me"));
assertTrue(f.input.isDisabled());
assertTrue(get(f.controller, "callButton", Button.class).isDisabled());
assertTrue(get(f.controller, "foldButton", Button.class).isDisabled());
assertTrue(get(f.controller, "raiseButton", Button.class).isDisabled());
}
/** Creates a minimal game state for testing. */
private GameState createGameState() {
GameState state = new GameState();
Player me = new Player(PlayerId.of("me"), 20000);
Player other = new Player(PlayerId.of("other"), 20000);
state.players = List.of(me, other);
state.phase = "PREFLOP";
state.currentBet = 200;
state.activePlayer = 0;
return state;
}
/** Loads the JavaFX controller and its UI components. */
private Fixture load() throws Exception {
URL url = getClass().getResource("/ui-structure/gameuicomponents/Taskbar.fxml");
assertNotNull(url);
FXMLLoader loader = new FXMLLoader(url);
Parent root = loader.load();
root.applyCss();
root.layout();
TaskbarController controller = loader.getController();
TextField input = get(controller, "taskbarInput", TextField.class);
Button bet = get(controller, "betButton", Button.class);
return new Fixture(controller, input, bet);
}
private record Fixture(TaskbarController controller, TextField input, Button betButton) {}
private static void setText(TextField field, String value) {
field.setText(value);
}
private static void invokePrivate(Object obj, String method) throws Exception {
Method m = obj.getClass().getDeclaredMethod(method);
m.setAccessible(true);
m.invoke(obj);
}
private static <T> T get(Object obj, String field, Class<T> type) throws Exception {
Field f = obj.getClass().getDeclaredField(field);
f.setAccessible(true);
return type.cast(f.get(obj));
}
/** Sets a private field value using reflection. */
private static void setField(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
/** Gets a private field value using reflection. */
// private static <T> T getField(Object obj, String fieldName, Class<T> type) throws Exception {
// Field field = obj.getClass().getDeclaredField(fieldName);
// field.setAccessible(true);
// return type.cast(field.get(obj));
// }
}
@@ -1,6 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.TestServer;
import javafx.scene.layout.GridPane;
import org.junit.jupiter.api.*;
@@ -8,15 +9,14 @@ class LobbyButtonGridManagerTest {
LobbyButtonGridManager gridManager;
LobbyButtonTranslationManager translationManager;
GridPane gridPane;
ch.unibas.dmi.dbis.cs108.casono.client.network.TestServer testServer;
TestServer testServer;
@BeforeEach
void setUp() throws Exception {
gridPane = new GridPane();
translationManager = LobbyButtonTranslationManager.getInstance();
translationManager.getButtonIdToLobbyId().clear();
// Start an in-process test server and connect a real ClientService to it.
testServer = new ch.unibas.dmi.dbis.cs108.casono.client.network.TestServer();
testServer = new TestServer();
String host = "127.0.0.1";
int port = testServer.getPort();
ClientService clientService = new ClientService(host, port);
@@ -0,0 +1,94 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
public class TokenClassifierTest {
@Test
void testClassifySimpleCommand() {
List<RawToken> raw = Tokenizer.tokenize("PING");
List<Token> tokens = TokenClassifier.classify(raw);
assertEquals(2, tokens.size());
assertEquals(TokenType.COMMAND, tokens.get(0).type());
assertEquals("PING", tokens.get(0).value());
assertEquals(TokenType.EOF, tokens.get(1).type());
}
@Test
void testCommandWithParameter() {
List<RawToken> raw = Tokenizer.tokenize("ANSWER VALUE=42");
List<Token> tokens = TokenClassifier.classify(raw);
assertEquals(5, tokens.size());
assertEquals(TokenType.COMMAND, tokens.get(0).type());
assertEquals(TokenType.KEY, tokens.get(1).type());
assertEquals(TokenType.SEPARATOR, tokens.get(2).type());
assertEquals(TokenType.VALUE, tokens.get(3).type());
assertEquals("42", tokens.get(3).value());
assertEquals(TokenType.EOF, tokens.get(4).type());
}
@Test
void testStringValue() {
List<RawToken> raw = Tokenizer.tokenize("GREET MSG='Hello World'");
List<Token> tokens = TokenClassifier.classify(raw);
assertEquals(5, tokens.size());
assertEquals(TokenType.COMMAND, tokens.get(0).type());
assertEquals(TokenType.KEY, tokens.get(1).type());
assertEquals(TokenType.SEPARATOR, tokens.get(2).type());
assertEquals(TokenType.VALUE, tokens.get(3).type());
assertEquals("Hello World", tokens.get(3).value());
}
@Test
void testUnexpectedStringLiteralThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD 'oops'");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertTrue(ex.getMessage().contains("Unexpected string literal"));
}
@Test
void testMissingValueAfterSeparatorThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD KEY=");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected VALUE after '='", ex.getMessage());
}
@Test
void testMissingKeyBeforeSeparatorThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD =VALUE");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected KEY before '='", ex.getMessage());
}
@Test
void testNextWordIsKeyThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD KEY1=KEY2=42");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected VALUE after '='", ex.getMessage());
}
@Test
void testEmptyRawTokensThrows() {
List<RawToken> raw = new ArrayList<>();
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected COMMAND as first token", ex.getMessage());
assertEquals(1, ex.getLine());
assertEquals(1, ex.getColumn());
}
}
@@ -0,0 +1,211 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
class TokenizerTest {
@Test
void testEofAlwaysPresent() {
List<RawToken> tokens = Tokenizer.tokenize("");
assertEquals(1, tokens.size());
assertEquals(RawTokenType.EOF, tokens.get(0).type());
}
@Test
void testSimpleCommand() {
List<RawToken> tokens = Tokenizer.tokenize("PING");
assertEquals(2, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("PING", tokens.get(0).value());
assertEquals(RawTokenType.EOF, tokens.get(1).type());
}
@Test
void testSimpleCommandWithUnderscore() {
List<RawToken> tokens = Tokenizer.tokenize("JOIN_GAME");
assertEquals(2, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("JOIN_GAME", tokens.get(0).value());
assertEquals(RawTokenType.EOF, tokens.get(1).type());
}
@Test
void testCommandWithOneParameter() {
List<RawToken> tokens = Tokenizer.tokenize("GET VALUE=42");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GET", tokens.get(0).value());
// Parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("VALUE", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.WORD, tokens.get(3).type());
assertEquals("42", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testCommandWithOneParameterWhitespacesBetweenSeperator() {
List<RawToken> tokens = Tokenizer.tokenize("GET VALUE = 42");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GET", tokens.get(0).value());
// Parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("VALUE", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.WORD, tokens.get(3).type());
assertEquals("42", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testCommandWithOneParameterAndNewline() {
List<RawToken> tokens = Tokenizer.tokenize("GET\nVALUE=42");
assertEquals(6, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GET", tokens.get(0).value());
// Parameter
assertEquals(RawTokenType.WORD, tokens.get(2).type());
assertEquals("VALUE", tokens.get(2).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(3).type());
assertEquals("=", tokens.get(3).value());
assertEquals(RawTokenType.WORD, tokens.get(4).type());
assertEquals("42", tokens.get(4).value());
assertEquals(RawTokenType.EOF, tokens.get(5).type());
}
@Test
void testCommandWithStringParameter() {
List<RawToken> tokens = Tokenizer.tokenize("GREET MSG='Hello World'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GREET", tokens.get(0).value());
// First parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("MSG", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("Hello World", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testCommandWithMultilineStringParameter() {
List<RawToken> tokens = Tokenizer.tokenize("GREET MSG='Hello\nWorld'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GREET", tokens.get(0).value());
// First parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("MSG", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("Hello\nWorld", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testWhitespace() {
List<RawToken> tokens = Tokenizer.tokenize(" \t PING");
assertEquals(2, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("PING", tokens.get(0).value());
}
@Test
void testStringWithEscapedQuote() {
List<RawToken> tokens =
Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\\'s a wonderful day'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("it's a wonderful day", tokens.get(3).value());
}
@Test
void testStringWithWronglyEscapedQuote() {
TokenizerException ex =
assertThrows(
TokenizerException.class,
() -> Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\'s a wonderful day'"));
assertEquals("Unterminated string literal", ex.getMessage());
}
@Test
void testStringWithEscapedBackslash() {
List<RawToken> tokens = Tokenizer.tokenize("EXECUTE_COMMAND CMD='\\whoami'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("\\whoami", tokens.get(3).value());
}
@Test
void testColumnTracking() {
List<RawToken> tokens = Tokenizer.tokenize("GET VAL=1");
assertEquals(5, tokens.size());
assertEquals(1, tokens.get(0).column());
assertEquals(5, tokens.get(1).column());
assertEquals(8, tokens.get(2).column());
assertEquals(9, tokens.get(3).column());
}
@Test
void testUnterminatedStringThrows() {
TokenizerException ex =
assertThrows(
TokenizerException.class, () -> Tokenizer.tokenize("GREETING = 'unclosed"));
assertEquals(1, ex.getLine());
assertEquals(12, ex.getColumn());
assertTrue(ex.getMessage().contains("Unterminated string literal"));
}
@Test
void testUnexpectedCharacterThrows() {
TokenizerException ex =
assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("CMD @ KEY=VALUE"));
assertTrue(ex.getMessage().contains("Unexpected character '@'"));
assertEquals(1, ex.getLine());
}
@Test
void testEmptyStringValue() {
List<RawToken> tokens = Tokenizer.tokenize("cmd = ''");
assertEquals(4, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(2).type());
assertEquals("", tokens.get(2).value());
}
}
@@ -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"));
}
}