Compare commits

...

129 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
Jona Walpert 028c3adb0d Merge branch 'chore/remove-unused-imports-lobby-ui' into 'main'
Chore: Remove unused import from lobby ui code

See merge request cs108-fs26/Gruppe-13!139
2026-04-21 18:50:38 +00:00
Jona Walpert 19b555fb47 Chore: Remove unused import from lobby ui code 2026-04-21 20:48:03 +02:00
Jona Walpert 8a7fad11a9 Merge branch 'docs/highscore-protocol' into 'main'
docs: add highscore protocol commands (Issue #110)

Closes #110

See merge request cs108-fs26/Gruppe-13!138
2026-04-21 18:43:34 +00:00
Jona Walpert c0a7cadf7e docs: add highscore protocol commands (Issue #110) 2026-04-21 20:40:52 +02:00
Jona Walpert 2fd07bbf60 Merge branch 'feat/highscore-window-ui' into 'main'
Feat: Add highscore popup UI and lobby display

Closes #110

See merge request cs108-fs26/Gruppe-13!137
2026-04-21 18:35:40 +00:00
Jona Walpert 15c998d549 Feat: Delete window header and white background. Add mouse dragging ability to Highscore popup 2026-04-21 20:31:52 +02:00
Jona Walpert 062e66cdf9 Feat: Add highscore popup UI and lobby display
Highscore button in GameUI replaces "Info" Button wich was unused
(Issue #110)
2026-04-21 20:13:13 +02:00
Jona Walpert 7674ae57ad Merge branch 'feat/highscore-client-protocol' into 'main'
Feat: Add highscore client protocol integration (Issue #110)

See merge request cs108-fs26/Gruppe-13!136
2026-04-21 16:04:04 +00:00
Jona Walpert 5e79b45d90 Feat: Add highscore client protocol integration (Issue #110) 2026-04-21 17:58:49 +02:00
Jona Walpert 45411c4087 Merge branch 'feat/highscore-server-storage' into 'main'
Feat: Add GET_HIGHSCORES and CLEAR_HIGHSCORES server commands (Issue #110)

See merge request cs108-fs26/Gruppe-13!135
2026-04-21 15:52:06 +00:00
Jona Walpert cc7f8a8ef3 Feat: Add GET_HIGHSCORES and CLEAR_HIGHSCORES server commands (Issue #110) 2026-04-21 17:42:38 +02:00
Julian Kropff d3a00aebc0 Merge branch 'fix/game-turn-and-action-bugs' into 'main'
Fix: improve round management, betting validation and game engine test coverage

Closes #118

See merge request cs108-fs26/Gruppe-13!134
2026-04-19 18:31:45 +00:00
Julian Kropff ab2adbf311 Fix: resolve checkstyle magic number and apply spotless formatting 2026-04-19 20:20:57 +02:00
Julian Kropff 8099228d4f Test: improve GameEngine tests and add testPreflopEndsAfterOneActionPerActivePlayer and
testPreflopCountsOnlyNonFoldedPlayersForProgress
Refs #118
2026-04-19 20:11:21 +02:00
Julian Kropff ac83d63ea1 Fix: align RoundManager with Round logic fixes
Refs #118
2026-04-19 20:02:43 +02:00
Julian Kropff 3443b5e8f3 Fix: align TaskbarController with Round logic fixes
Refs #118
2026-04-19 20:02:02 +02:00
Julian Kropff ad7086e5a5 Fix: implement folding in GameController
Refs #118
2026-04-19 19:38:37 +02:00
Julian Kropff e39cbb4357 Fix: adjust GameState logic
Refs #118
2026-04-19 19:35:57 +02:00
Julian Kropff 238a156d91 Fix: round transition logic in RoundManager
Refs #118
2026-04-19 19:28:13 +02:00
Julian Kropff 621843167e Fix: Improve handleAction logic in GameEngine
Refs #118
2026-04-19 19:21:44 +02:00
Julian Kropff 215e7e2cae Fix: Improved validation for betting, calling, raising and folding in TaskbarController.
Prevents incorrect user input during gameplay.
Refs #118
2026-04-19 19:19:11 +02:00
Julian Kropff 03daf9c3b8 Fix: updateTaskbar in CasinoGameController
Refs #118
2026-04-19 18:51:25 +02:00
Julian Kropff d23a00e148 Fix: disable context-dependent buttons in taskbar 2026-04-16 21:18:42 +02:00
JK a5f0735a08 Merge branch 'main' into fix/game-turn-and-action-bugs 2026-04-16 13:41:46 +02:00
Jona Walpert 4855e937e6 Merge branch 'chore/clean-up-command-registry-imports' into 'main'
Chore: fix long imports to make code more readable

See merge request cs108-fs26/Gruppe-13!133
2026-04-16 08:34:39 +00:00
Jona Walpert 007e863878 Chore: fix long imports to make code more readable 2026-04-16 10:25:57 +02:00
Jona Walpert 56aa3c0215 Merge branch 'integration/116-change-username' into 'main'
Feat: Integrate username change flow across lobby, server and chat

See merge request cs108-fs26/Gruppe-13!132
2026-04-15 19:14:23 +00:00
Jona Walpert 417af2e8f7 Merge branch 'feat/116-change-username-in-lobby' into integration/116-change-username 2026-04-15 21:09:49 +02:00
Jona Walpert 893bcee9bf Merge branch 'feat/change-username-command-on-server-side' into integration/116-change-username 2026-04-15 21:09:26 +02:00
Jona Walpert 6d1b467116 Fix: Sent chat messages with current username at send time 2026-04-15 21:06:13 +02:00
Jona Walpert 4687566816 Feat: Added client-side USERNAME_CHANGED event handling with whisper migration 2026-04-15 21:06:05 +02:00
Jona Walpert d27a07f3e8 Fix: Canonicalized outgoing chat sender name from authenticated session user 2026-04-15 21:05:40 +02:00
Jona Walpert b86f18a11c Feat: Added in-game player-id rename propagation for active games 2026-04-15 21:05:25 +02:00
Jona Walpert 7ff274269e Feat: Added lobby-level player rename handling and mapping synchronization 2026-04-15 21:04:57 +02:00
Jona Walpert ec916aa98c Fix: Allowed no-op username changes when new name equals current name 2026-04-15 21:04:38 +02:00
Jona Walpert 190b30672a Feat: Added CHANGE_USERNAME server command, registration and documentation 2026-04-15 20:49:29 +02:00
Jona Walpert 11be6e2b9b Feat: Wired lobby controller login flow to support in-session username changes 2026-04-15 20:43:15 +02:00
Jona Walpert a8599cfb4c Feat: Added CHANGE_USERNAME request support in LobbyClient 2026-04-15 20:42:50 +02:00
Jona Walpert 08b9f652be Feat: Added runtime shared-username update helper in client bootstrap 2026-04-15 20:42:28 +02:00
Jona Walpert 3252c7e534 Feat: Enabled username input section in lobby UI 2026-04-15 20:41:31 +02:00
Jona Walpert 181f538038 fix: disable post-game buttons
Disable action buttons when game reaches FINISHED phase in UI
2026-04-15 00:11:23 +02:00
71 changed files with 5725 additions and 634 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>
@@ -1,8 +1,9 @@
# Protocol Document
This document describes the protocol for client-server communication in our application. It defines the structure of requests and responses, the supported commands along with their request parameters, response formats, and possible errors.
# Table of Contents
- [Protocol Document](#protocol-document)
- [Table of Contents](#table-of-contents)
- [General structure of requests](#general-structure-of-requests)
@@ -36,20 +37,27 @@ This document describes the protocol for client-server communication in our appl
- [Error Response](#error-response-4)
- [Example Request](#example-request-2)
- [Example Response](#example-response-2)
- [LOGOUT command](#logout-command)
- [CHANGE\_USERNAME command](#change_username-command)
- [Required pre-execution checks](#required-pre-execution-checks-3)
- [Request Parameters](#request-parameters-3)
- [Success Response](#success-response-3)
- [Error Response](#error-response-5)
- [Example Request](#example-request-3)
- [Example Response](#example-response-3)
- [LIST\_USERS command](#list_users-command)
- [LOGOUT command](#logout-command)
- [Required pre-execution checks](#required-pre-execution-checks-4)
- [Request Parameters](#request-parameters-4)
- [Success Response](#success-response-4)
- [Error Response](#error-response-6)
- [Example Request](#example-request-4)
- [Example Response](#example-response-4)
- [LIST\_USERS command](#list_users-command)
- [Required pre-execution checks](#required-pre-execution-checks-5)
- [Request Parameters](#request-parameters-5)
- [Success Response](#success-response-5)
- [Error Response](#error-response-7)
- [Example Request](#example-request-5)
- [Example Response](#example-response-5)
- [GET_LOBBY_LIST command](#get_lobby_list-command)
- [Required pre-execution checks](#required-pre-execution-checks)
- [Request Parameters](#request-parameters)
@@ -62,6 +70,18 @@ This document describes the protocol for client-server communication in our appl
- [Success Response](#success-response)
- [Example Request](#example-request)
- [Example Response](#example-response)
- [GET_HIGHSCORES command](#get_highscores-command)
- [Required pre-execution checks](#required-pre-execution-checks)
- [Request Parameters](#request-parameters)
- [Success Response](#success-response)
- [Example Request](#example-request)
- [Example Response](#example-response)
- [CLEAR_HIGHSCORES command](#clear_highscores-command)
- [Required pre-execution checks](#required-pre-execution-checks)
- [Request Parameters](#request-parameters)
- [Success Response](#success-response)
- [Example Request](#example-request)
- [Example Response](#example-response)
- [RAISE command](#raise-command)
- [CALL command](#call-command)
- [FOLD command](#fold-command)
@@ -91,50 +111,53 @@ This document describes the protocol for client-server communication in our appl
<!-- Please see the comments for copy n' paste ready examples -->
# General structure of requests
As mentioned before, our protocol is based on POP3.
As mentioned before, our protocol is based on POP3.
Each command is represented as a single line of text, starting with the command name followed by parameters. The server responds with a status line indicating success or failure, followed by the body.
Requests can have parameters that provide additional information for the command. Parameters are key-value pairs separated by an equal sign (`=`).
Responses are collections of key-value pairs, containing either a value or another collection, allowing for nested structures.
Responses are collections of key-value pairs, containing either a value or another collection, allowing for nested structures.
Each collection is ended with the `END` keyword.
# Preconditions
The serverside pipeline to process incoming requests consists of multiple stages.
Each of these stages can yield an error response if the request does not meet the requirements of that stage.
## Parsing
One of these stages is the parsing. It is responsible for parsing the raw request into a structured format that can be easily processed by the command handlers. It validates the syntax of the request as well.
### Error Response
| Code | Description |
| :-------------- | :---------------------------------------------------------------------------------------------- |
| `PARSING_ERROR` | The body of the request contains syntax errors (see message field of response for more details) |
## Command dispatching
After the request has been successfully parsed, the next stage is to dispatch the `PrimitiveRequest` to the appropriate `CommandParser`. This is done by the `CommandDispatcherDispatcher`, which uses the command name to determine which parser to use.
### Error Response
| Code | Description |
| :---------------- | :---------------------------------------------------------------- |
| `UNKNOWN_COMMAND` | The command is unknown to this server. No parser has been defined |
## Command parsing
Once the `PrimitiveRequest` has been dispatched to the appropriate `CommandParser`, the parser is responsible for parsing the parameters of the request and creating a `Request` that can be executed by the responsible `CommandHandler`.
### Error Response
| Code | Description |
| :------------------ | :------------------------------------------------------------------------------------------------ |
| `MISSING_PARAMETER` | A required parameter is missing from the request (see message field of response for more details) |
# Pre-execution checks
Pre-execution checks are reusable validation steps that can be registered on command handlers.
Pre-execution checks are reusable validation steps that can be registered on command handlers.
They are implemented as `HandlerCheck` instances and are executed by the `CommandHandlerExecutor` before the handler's main logic is invoked.
<!--
@@ -148,16 +171,17 @@ Description of the check, what it does and when it should be used.
-->
## UserLoggedInCheck
The `UserLoggedInCheck` is a common pre-execution check that verifies whether the user is logged in (i.e. has a user associated with his session).
### Error Response
| Code | Description |
| :------------------- | :------------------------ |
| `USER_NOT_LOGGED_IN` | The user is not logged in |
# Commands
Commands are the core of our protocol, representing the various actions that clients can request from the server. Each command has a unique name and may require specific parameters in addition to pre-execution checks.
The server processes these commands and responds accordingly.
@@ -212,41 +236,50 @@ END
-->
## PING command
The `PING` command is a simple command that can be used to check if the server is responsive.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
No response fields.
### Example Request
```
PING
```
```
### Example Response
```
+OK
END
```
## CHECK_USERNAME command
The `CHECK_USERNAME` command is used to check if a username is already taken by another user. Additional users can still log in with the same username, but their name will be substituted with a suffix.
### Required pre-execution checks
None.
### Request Parameters
| Parameter Name | Type | Optional | Description |
| :------------- | :------- | :------- | :------------------------------------- |
| `USERNAME` | `String` | no | The username to check for availability |
### Success Response
| Field | Type | Description |
| :------- | :--------------------------- | :---------------------------------------------------------------------- |
| `STATUS` | `Enum<UsernameAvailability>` | Member of enum indicating if the username is available or already taken |
@@ -257,11 +290,13 @@ None.
| `TAKEN` | Username is already in use |
### Example Request
```
CHECK_USERNAME USERNAME='Lars'
```
### Example Response
```
+OK
STATUS=FREE
@@ -269,33 +304,40 @@ END
```
## LOGIN command
The `LOGIN` command is used to log in a user with a specified username. If the username is already taken by another user, the server will append a suffix to the username to make it unique.
### Required pre-execution checks
None.
### Request Parameters
| Parameter Name | Type | Optional | Description |
| :------------- | :------- | :------- | :----------------------------------- |
| `USERNAME` | `String` | no | The username to create the user with |
### Success Response
| Field | Type | Description |
| :--------- | :---------------------------------------------------------------------- | :-------------------------------------------------------------------- |
| `USERNAME` | `String` | Username of the newly created user, can differ from the requested one |
| `ID` | [`UUID`](https://docs.oracle.com/javase/8/docs/api/java/util/UUID.html) | The ID of the created user |
### Error Response
| Code | Description |
| :------------------ | :----------------------------------------------------------------------------- |
| `ALREADY_LOGGED_IN` | The session is already associated with a user, logging in again is prohibited. |
### Example Request
```
LOGIN USERNAME='Lars'
```
### Example Response
```
+OK
USERNAME='Lars_1234'
@@ -303,45 +345,100 @@ LOGIN USERNAME='Lars'
END
```
## CHANGE_USERNAME command
## LOGOUT command
Description of the command, what it does, and when it should be used.
The `CHANGE_USERNAME` command is used to change the username of an already logged-in user. The request is tied to the current session and updates all affected server-side mappings.
### Required pre-execution checks
None.
### Request Parameters
| Parameter Name | Type | Optional | Description |
| :------------- | :------- | :------- | :------------------------------------ |
| `USERNAME` | `String` | no | The new username for the active user |
### Success Response
| Field | Type | Description |
| :--------- | :---------------------------------------------------------------------- | :-------------------------------------- |
| `USERNAME` | `String` | Effective username after rename |
| `ID` | [`UUID`](https://docs.oracle.com/javase/8/docs/api/java/util/UUID.html) | The ID of the renamed user |
### Error Response
| Code | Description |
| :------------------- | :-------------------------------------------------------------------- |
| `USER_NOT_LOGGED_IN` | No active user is associated with this session. |
| `INVALID_USERNAME` | Username contains disallowed characters or is empty. |
| `USERNAME_TAKEN` | Requested username is already used by another user. |
| `RENAME_CONFLICT` | Rename could not be propagated to all current lobby/game structures. |
### Example Request
```
CHANGE_USERNAME USERNAME='Lars_New'
```
### Example Response
```
+OK
USERNAME='Lars_New'
ID=e47a671e-2b2a-42df-bb82-953fe2ebd307
END
```
## LOGOUT command
Description of the command, what it does, and when it should be used.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
No response fields.
### Error Response
| Code | Description |
| :------------------- | :--------------------------------------------------------------- |
| `NO_USER_ASSOCIATED` | The session has no user associated, logging out is not possible. |
### Example Request
```
LOGOUT
```
### Example Response
```
+OK
END
```
## LIST_USERS command
The `LIST_USERS` command is used to retrieve a list of all currently logged-in users.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
| Field | Type | Description |
| :------ | :----------------- | :--------------------------------------- |
| `USERS` | `Collection<User>` | Collection of all users currently online |
@@ -351,16 +448,18 @@ No parameters.
| `USERNAME` | `String` | Username of the newly created user, can differ from the requested one |
| `ID` | [`UUID`](https://docs.oracle.com/javase/8/docs/api/java/util/UUID.html) | The ID of the created user |
### Error Response
None.
### Example Request
```
LIST_USERS
```
### Example Response
```
+OK
USERS
@@ -381,8 +480,11 @@ END
```
## SEND_MESSAGE command
The `SEND_MESSAGE` command is used to transfer the chat message sent by a user to the server.
### Required pre-execution checks
None.
### Request Parameters
@@ -403,28 +505,36 @@ None.
| `WHISPER` | Message is for the whisper chat |
### Success Response
No response fields.
### Error Response
None.
### Example Request
```
SEND_MESSAGE TYPE=GLOBAL GAME=1 USER=player1 TARGET=null TIME='10:30' TEXT='Hello World'
```
### Example Response
```
+OK
END
```
## GET_MESSAGE_COUNT command
The `GET_MESSAGE_COUNT` is used to get the current number of messages that are stored in the queue for a client.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
@@ -434,17 +544,19 @@ No parameters.
| `COUNT` | `int` | The current number of messages |
### Error Response
| Code | Description |
| :------------------- |:---------------------------------------------------------------------------------|
| `NO_USER_ASSOCIATED` | The session has no user associated, there is no queue of messages for the client |
### Example Request
```
GET_MESSAGE_COUNT
```
### Example Response
```
+OK
COUNT=10
@@ -452,14 +564,19 @@ END
```
## GET_NEXT_MESSAGE command
The `GET_NEXT_MESSAGE` command is used to get the next message stored in a queue for the client.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
| Field | Type | Description |
|:---------|:----------------|:-------------------------------------------------------------------|
| `TYPE` | `Enum<ChatType` | Member of Enum, indicating with chat type is used |
@@ -476,16 +593,19 @@ No parameters.
| `WHISPER` | Message is for the whisper chat |
### Error Response
| Code | Description |
| :------------------- |:---------------------------------------------------------------------------------|
| `NO_USER_ASSOCIATED` | The session has no user associated, there is no queue of messages for the client |
### Example Request
```
GET_NEXT_MESSAGE
```
### Example Response
```
+OK
TYPE=GLOBAL GAME=-1 USER=player1 TARGET=null TIME=9:30 TEXT="Guten Tag"
@@ -613,9 +733,11 @@ END
The `GET_LOBBY_LIST` command requests the server to return a list of currently available lobbies with basic metadata.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Implementation notes
@@ -667,9 +789,11 @@ END
The `GET_GAME_STATE` command returns a snapshot of the current game for a lobby. It includes global fields (`PHASE`, `POT`, `CURRENT_BET`, `DEALER`, `ACTIVE_PLAYER`), repeated `CARD` blocks for community cards and repeated `PLAYER` blocks for per-player information. If the requester is a player in the game, their hole cards are included inside their `PLAYER` block as nested `CARD` blocks.
### Required pre-execution checks
- [`UserLoggedInCheck`](#userloggedincheck)
### Request Parameters
| Parameter Name | Type | Optional | Description |
| :------------- | :----- | :------: | :---------- |
| `GAME_ID` | `int` | yes | Numeric id of the lobby/game to query. If omitted the server will resolve the lobby by the requesting session's associated user.
@@ -1114,9 +1238,11 @@ END
The `GET_LOBBY_STATUS` command requests the server to return the current state of a lobby, including the list of players and their ready state.
### Required pre-execution checks
None.
### Request Parameters
| Parameter Name | Type | Optional | Description |
| :------------- | :--- | :------: | :---------- |
| `ID` | `int` | no | Numeric id of the target lobby |
@@ -1151,6 +1277,7 @@ END
```
### Error Response
| Code | Description |
| :--- | :---------- |
| `LOBBY_NOT_FOUND` | The specified lobby id does not exist |
@@ -1222,4 +1349,72 @@ END
CODE=LOBBIES_FULL
MESSAGE=Maximum number of 8 lobbies reached
END
```
```
## GET_HIGHSCORES command
The `GET_HIGHSCORES` command returns the stored highscore list. The server sends a `HIGHSCORES` block containing repeated `HIGHSCORE` fields. Each entry is already formatted as `timestamp | winner name`.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
| Field | Type | Description |
| :----------- | :---------------------- | :--------------------------------------- |
| `HIGHSCORES` | `Collection<Highscore>` | Collection of formatted highscore lines. |
| Fields of `Highscore` | Type | Description |
| :-------------------- | :------- | :-------------------------------------------------- |
| `HIGHSCORE` | `String` | One formatted entry in `yyyy-MM-dd HH:mm:ss | name` |
### Example Request
```
GET_HIGHSCORES
```
### Example Response
```
+OK
HIGHSCORES
HIGHSCORE='2026-04-21 15:19:23 | Jona'
HIGHSCORE='2026-04-21 16:02:11 | Lars'
END
END
```
## CLEAR_HIGHSCORES command
The `CLEAR_HIGHSCORES` command deletes all stored highscore entries on the server.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
No response fields.
### Example Request
```
CLEAR_HIGHSCORES
```
### Example Response
```
+OK
END
```
@@ -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 {
@@ -39,6 +39,17 @@ public class ClientApp {
return sharedUsername;
}
public static void updateSharedUsername(String username) {
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) {
sharedUsername = username;
LOGGER.info("sharedUsername set to '{}'", getSharedUsername());
@@ -48,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) {
@@ -77,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) {
@@ -130,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);
}
@@ -1,15 +1,22 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ChatClient;
import ch.unibas.dmi.dbis.cs108.casono.client.network.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;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.WeakHashMap;
import java.util.function.Consumer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jspecify.annotations.Nullable;
@@ -24,7 +31,7 @@ public class ChatController {
private static final Map<ClientService, ChatController> ACTIVE_CONTROLLERS =
new WeakHashMap<>();
private final String username;
private volatile String username;
private final ClientService clientService;
@@ -34,9 +41,14 @@ 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;
public record ChatKey(ChatType type, @Nullable String targetUser) {
public ChatKey(ChatType type) {
@@ -55,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;
/**
@@ -71,8 +89,11 @@ public class ChatController {
localUserList = new ArrayList<>();
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);
this.timer = new Timer(true);
timer.schedule(
@@ -118,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);
}
/**
@@ -131,6 +152,7 @@ public class ChatController {
* <p>All Messages get added to a particular {@link ChatModel}, if the checks passed.
*/
public void receiveMessage() {
String currentUsername = getCurrentUsername();
List<Message> newMessages = chatClient.getMessages();
if (!newMessages.isEmpty()) {
for (Message msg : newMessages) {
@@ -146,16 +168,17 @@ public class ChatController {
(_key) ->
new ChatModel(
ChatType.LOBBY,
username,
currentUsername,
msg.lobbyId,
null))
.addMessage(msg);
}
break;
case ChatType.WHISPER:
if (msg.target.equals(username) || msg.sender.equals(username)) {
if (msg.target.equals(currentUsername)
|| msg.sender.equals(currentUsername)) {
ChatKey key;
if (msg.target.equals(username)) {
if (msg.target.equals(currentUsername)) {
key = new ChatKey(ChatType.WHISPER, msg.sender);
} else {
key = new ChatKey(ChatType.WHISPER, msg.target);
@@ -166,7 +189,7 @@ public class ChatController {
ChatModel chatModel =
new ChatModel(
ChatType.WHISPER,
username,
currentUsername,
lobbyId,
key.targetUser());
chatBoxController.addWhisperChat(key.targetUser(), chatModel);
@@ -203,13 +226,29 @@ public class ChatController {
*/
public synchronized void checkWhisperUsers() {
List<String> users = chatClient.getUsers();
logger.info(users);
if (!users.isEmpty()) {
for (String user : users) {
String value = user.split("\\=")[1];
logger.info(value);
addWhisperUser(value);
Set<String> remoteUsers = new HashSet<>();
String currentUsername = getCurrentUsername();
for (String user : users) {
if (user == null || !user.contains("=")) {
continue;
}
String value = user.split("\\=", 2)[1].trim();
if (value.isBlank() || value.equals(currentUsername)) {
continue;
}
remoteUsers.add(value);
}
for (String known : new ArrayList<>(localUserList)) {
if (!remoteUsers.contains(known)) {
localUserList.remove(known);
chatBoxController.removeWhisperUser(known);
}
}
for (String remote : remoteUsers) {
addWhisperUser(remote);
}
}
@@ -222,16 +261,110 @@ public class ChatController {
if (user == null || user.isBlank()) {
return;
}
if (!(localUserList.contains(user) || user.equals(username))) {
if (!(localUserList.contains(user) || user.equals(getCurrentUsername()))) {
localUserList.add(user);
logger.info("adding new whisper user");
chatBoxController.addWhisperUser(user);
}
}
public synchronized void updateUsername(String newUsername) {
if (newUsername == null || newUsername.isBlank()) {
return;
}
String oldUsername = this.username;
this.username = newUsername.trim();
chatBoxController.setUsername(this.username);
if (oldUsername != null && !oldUsername.equals(this.username)) {
localUserList.remove(oldUsername);
chatBoxController.removeWhisperUser(oldUsername);
}
}
private void handleServerEvent(List<String> lines) {
if (lines == null || lines.isEmpty()) {
return;
}
List<RequestParameter> params;
try {
params = ClientService.convertToRequestParameters(lines);
} catch (RuntimeException e) {
return;
}
String event = null;
String oldUsername = null;
String newUsername = null;
for (RequestParameter p : params) {
if ("EVENT".equalsIgnoreCase(p.key())) {
event = p.value();
} else if ("OLD_USERNAME".equalsIgnoreCase(p.key())) {
oldUsername = p.value();
} else if ("NEW_USERNAME".equalsIgnoreCase(p.key())) {
newUsername = p.value();
}
}
if (!"USERNAME_CHANGED".equalsIgnoreCase(event)) {
return;
}
applyUsernameMigration(oldUsername, newUsername);
}
private synchronized void applyUsernameMigration(String oldUsername, String newUsername) {
if (oldUsername == null
|| newUsername == null
|| oldUsername.isBlank()
|| newUsername.isBlank()
|| oldUsername.equals(newUsername)) {
return;
}
String currentUsername = getCurrentUsername();
if (oldUsername.equals(currentUsername)) {
updateUsername(newUsername);
}
ChatKey oldKey = new ChatKey(ChatType.WHISPER, oldUsername);
ChatKey newKey = new ChatKey(ChatType.WHISPER, newUsername);
ChatModel oldModel = chatModelMap.remove(oldKey);
ChatModel existingNewModel = chatModelMap.get(newKey);
if (oldModel != null) {
oldModel.setTarget(newUsername);
if (existingNewModel == null) {
chatModelMap.put(newKey, oldModel);
} else {
// Merge possible parallel history into the already existing new-key model.
for (Message msg : oldModel.messages) {
existingNewModel.addMessage(msg);
}
}
chatBoxController.renameWhisperUser(oldUsername, newUsername);
}
localUserList.remove(oldUsername);
chatBoxController.removeWhisperUser(oldUsername);
if (!newUsername.equals(getCurrentUsername())) {
addWhisperUser(newUsername);
}
}
public String getCurrentUsername() {
String shared = ClientApp.getSharedUsername();
if (shared != null && !shared.isBlank()) {
return shared.trim();
}
return username;
}
/** Stops polling background tasks for this chat controller instance. */
public void shutdown() {
timer.cancel();
clientService.removeEventListener(serverEventListener);
synchronized (ACTIVE_CONTROLLERS) {
if (ACTIVE_CONTROLLERS.get(clientService) == this) {
ACTIVE_CONTROLLERS.remove(clientService);
@@ -22,7 +22,7 @@ public class ChatModel {
public final String username;
/** The person to send the message to If the chat is a whisper chat */
private final String target;
private String target;
private final IntegerProperty count;
@@ -82,4 +82,8 @@ public class ChatModel {
public String getTarget() {
return target;
}
public void setTarget(String target) {
this.target = target;
}
}
@@ -10,6 +10,7 @@ import java.util.List;
public class GameState {
public List<Player> players = new ArrayList<>();
public List<Card> communityCards = new ArrayList<>();
public List<String> highscoreEntries = new ArrayList<>();
public String phase;
public int pot;
@@ -155,6 +155,10 @@ public class GameClient {
s.winnerIndex = intVal(l);
yield true;
}
case String l when l.startsWith("HIGHSCORE=") -> {
s.highscoreEntries.add(value(l));
yield true;
}
default -> false;
};
}
@@ -81,6 +81,26 @@ public class LobbyClient {
throw new RuntimeException("No LOBBY_ID in response: " + lines);
}
/** Fetches the global highscores from the server. */
public List<String> getHighscores() {
List<String> lines = client.processCommand("GET_HIGHSCORES");
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
List<String> entries = new ArrayList<>();
for (RequestParameter p : params) {
if ("HIGHSCORE".equalsIgnoreCase(p.key())) {
entries.add(p.value());
}
}
return entries;
}
/** Clears all global highscores on the server. */
public void clearHighscores() {
client.processCommand("CLEAR_HIGHSCORES");
}
/**
* Request the server to return the id of the lobby that the client is currently in.
*
@@ -115,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);
@@ -131,6 +152,36 @@ 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.
*
* @param newUsername desired new username
* @return a {@link LoginResult} containing assigned username and id as returned by the server
*/
public LoginResult changeUsername(String newUsername) {
List<String> lines = client.processCommand("CHANGE_USERNAME USERNAME=" + newUsername);
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
String assigned = newUsername;
String id = null;
for (RequestParameter p : params) {
if ("USERNAME".equalsIgnoreCase(p.key())) {
assigned = p.value();
} else if ("ID".equalsIgnoreCase(p.key())) {
id = p.value();
}
}
return new LoginResult(assigned, id);
}
/**
* Request the server for the list of available lobbies.
*
@@ -152,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.
@@ -60,6 +59,12 @@ public class ChatBoxController {
usernameTabMap = new HashMap<>();
}
public void setUsername(String username) {
if (username != null && !username.isBlank()) {
this.username = username.trim();
}
}
/**
* Initializes the chat interface by creating the global chat model and adding the corresponding
* "GLOBAL" tab to the interface. It also registers the global chat in the {@link
@@ -67,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());
}
@@ -84,6 +88,11 @@ public class ChatBoxController {
public void addWhisperUser(String targetUserName) {
Platform.runLater(
() -> {
for (MenuItem existing : addWhisperChatButton.getItems()) {
if (targetUserName.equals(existing.getText())) {
return;
}
}
MenuItem menuItem = new MenuItem(targetUserName);
addWhisperChatButton.getItems().add(menuItem);
menuItem.setOnAction(
@@ -98,6 +107,54 @@ public class ChatBoxController {
});
}
public void removeWhisperUser(String targetUserName) {
Platform.runLater(
() ->
addWhisperChatButton
.getItems()
.removeIf(item -> targetUserName.equals(item.getText())));
}
public void renameWhisperUser(String oldUsername, String newUsername) {
if (oldUsername == null
|| newUsername == null
|| oldUsername.isBlank()
|| newUsername.isBlank()
|| oldUsername.equals(newUsername)) {
return;
}
Platform.runLater(
() -> {
for (MenuItem item : addWhisperChatButton.getItems()) {
if (oldUsername.equals(item.getText())) {
item.setText(newUsername);
item.setOnAction(
event ->
addWhisperChat(
newUsername,
new ChatModel(
ChatType.WHISPER,
username,
-1,
newUsername)));
break;
}
}
Tab tab = usernameTabMap.remove(oldUsername);
if (tab != null) {
tab.setText(newUsername);
usernameTabMap.put(newUsername, tab);
}
int idx = activeWhisperChats.indexOf(oldUsername);
if (idx >= 0) {
activeWhisperChats.set(idx, newUsername);
}
});
}
/**
* Creates a new private (whisper) chat model for a specific target user, registers it within
* the chat system, and opens a new chat tab.
@@ -107,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));
}
@@ -125,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(
@@ -134,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);
@@ -180,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);
}
}
}
}
}
@@ -68,11 +68,12 @@ public class ChatViewController implements Initializable {
String message = inputField.getText().trim();
if (!message.isEmpty()) {
inputField.clear();
String currentUsername = controller.getCurrentUsername();
Message msg =
new Message(
chatModel.getChattype(),
chatModel.lobbyId,
username,
currentUsername,
chatModel.getTarget(),
message);
controller.onSendToNetwork(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;
}
/**
@@ -633,12 +770,13 @@ public class CasinoGameController {
*/
private void updateTaskbar(GameState s) {
TaskbarController controller = resolveTaskbarController();
if (controller != null) {
if (gameService != null && myPlayerId != null) {
controller.setGameService(gameService, myPlayerId);
}
controller.update(s, myPlayerId);
if (controller == null || myPlayerId == null) {
return;
}
if (gameService != null) {
controller.setGameService(gameService, myPlayerId);
}
controller.update(s, myPlayerId);
}
/**
@@ -649,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;
@@ -676,6 +816,7 @@ public class CasinoGameController {
safeRefresh(player1Controller);
safeRefresh(player2Controller);
safeRefresh(player3Controller);
updateActiveTurnHighlights(players, activePlayerIndex);
}
/**
@@ -700,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;
@@ -772,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);
}
}
/**
@@ -1320,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)(\\?.*)?$");
}
}
@@ -0,0 +1,114 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import java.util.List;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseEvent;
import javafx.stage.Stage;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/** Controller for the highscore popup window. */
public class HighscoreViewController {
private static final Logger LOGGER = LogManager.getLogger(HighscoreViewController.class);
@FXML private ListView<String> highscoreList;
@FXML private Label statusLabel;
@FXML private Node highscoreRoot;
private LobbyClient lobbyClient;
private double dragOffsetX;
private double dragOffsetY;
public void setLobbyClient(LobbyClient lobbyClient) {
this.lobbyClient = lobbyClient;
}
/** Starts dragging the window from anywhere in the popup. */
@FXML
public void onPopupPressed(MouseEvent event) {
Stage stage = getStage();
if (stage == null) {
return;
}
dragOffsetX = stage.getX() - event.getScreenX();
dragOffsetY = stage.getY() - event.getScreenY();
}
/** Moves the popup window while the mouse is dragged anywhere on the popup. */
@FXML
public void onPopupDragged(MouseEvent event) {
Stage stage = getStage();
if (stage == null) {
return;
}
stage.setX(event.getScreenX() + dragOffsetX);
stage.setY(event.getScreenY() + dragOffsetY);
}
/** Returns the popup stage if the root is already attached to a scene. */
private Stage getStage() {
if (highscoreRoot == null || highscoreRoot.getScene() == null) {
return null;
}
return (Stage) highscoreRoot.getScene().getWindow();
}
/** Loads current highscores from the server and refreshes the list. */
@FXML
public void refreshHighscores() {
if (highscoreList == null || statusLabel == null) {
return;
}
if (lobbyClient == null) {
statusLabel.setText("Lobby client not initialized.");
return;
}
try {
List<String> entries = lobbyClient.getHighscores();
highscoreList.getItems().setAll(entries);
statusLabel.setText(
entries.isEmpty() ? "No highscores yet." : entries.size() + " entries loaded.");
} catch (RuntimeException e) {
LOGGER.error("Failed to load highscores: {}", e.getMessage());
statusLabel.setText("Failed to load highscores.");
}
}
/** Clears all highscores globally on the server and reloads the list. */
@FXML
public void clearHighscores() {
if (lobbyClient == null || statusLabel == null) {
return;
}
try {
lobbyClient.clearHighscores();
refreshHighscores();
statusLabel.setText("Highscores cleared.");
} catch (RuntimeException e) {
LOGGER.error("Failed to clear highscores: {}", e.getMessage());
statusLabel.setText("Failed to clear highscores.");
}
}
/** Closes the highscore popup window. */
@FXML
public void closeWindow() {
if (highscoreList == null || highscoreList.getScene() == null) {
return;
}
Stage stage = (Stage) highscoreList.getScene().getWindow();
stage.close();
}
}
@@ -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);
}
}
}
@@ -4,12 +4,15 @@ 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.gameuicomponents.HighscoreViewController;
import java.io.IOException;
import java.net.URL;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
@@ -20,6 +23,8 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -50,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() {
@@ -104,6 +109,13 @@ public class CasinomainuiController {
casinoTable.getChildren().add(gridManager.getGridPane());
gridManager.renderLobbyButtons();
String sharedUsername = ClientApp.getSharedUsername();
updateUsernameFieldPresentation(sharedUsername);
if (loginButton != null) {
loginButton.setText(
sharedUsername != null && !sharedUsername.isBlank() ? "Change Name" : "Login");
}
initializeChat(clientService);
}
@@ -129,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());
}
@@ -156,7 +169,7 @@ public class CasinomainuiController {
/** Handles the login button action. Validates input and calls LobbyClient.login(). */
@FXML
public void handleLoginButton() {
String username = usernameField.getText();
String username = usernameField == null ? null : usernameField.getText();
if (username == null || username.isBlank()) {
showAlert("Please enter a username.");
return;
@@ -170,12 +183,63 @@ public class CasinomainuiController {
showAlert("Offline mode: cannot send login to server.");
return;
}
String trimmed = username.trim();
try {
lobbyClient.login(username);
showAlert("Login sent: " + username);
var result = lobbyClient.login(trimmed);
String assigned = result != null ? result.getUsername() : trimmed;
ClientApp.updateSharedUsername(assigned);
if (chatController != null) {
chatController.updateUsername(assigned);
}
updateUsernameFieldPresentation(assigned);
if (loginButton != null) {
loginButton.setText("Change Name");
}
return;
} catch (RuntimeException loginError) {
if (!containsProtocolError(loginError, "ALREADY_LOGGED_IN")) {
LOGGER.error("Login failed: {}", loginError.getMessage());
showAlert("Login failed: " + loginError.getMessage());
return;
}
LOGGER.info("Session already logged in, trying username change");
}
try {
var result = lobbyClient.changeUsername(trimmed);
String assigned = result != null ? result.getUsername() : trimmed;
ClientApp.updateSharedUsername(assigned);
if (chatController != null) {
chatController.updateUsername(assigned);
}
updateUsernameFieldPresentation(assigned);
if (loginButton != null) {
loginButton.setText("Change Name");
}
} catch (RuntimeException e) {
LOGGER.error("Login failed: {}", e.getMessage());
showAlert("Login failed: " + e.getMessage());
LOGGER.error("Change username failed: {}", e.getMessage());
showAlert("Change username failed: " + e.getMessage());
}
}
private boolean containsProtocolError(RuntimeException error, String code) {
String msg = error.getMessage();
return msg != null && msg.contains(code);
}
private void updateUsernameFieldPresentation(String username) {
if (usernameField == null) {
return;
}
if (username != null && !username.isBlank()) {
usernameField.setText(username.trim());
usernameField.positionCaret(usernameField.getText().length());
usernameField.setPromptText("Username");
} else {
usernameField.clear();
usernameField.setPromptText("Username");
}
}
@@ -229,4 +293,36 @@ public class CasinomainuiController {
LOGGER.error("Failed to create or add lobby: {}", e.getMessage());
}
}
/** Opens the highscore popup from the lobby UI. */
@FXML
public void handleOpenHighscores() {
try {
FXMLLoader loader =
new FXMLLoader(
getClass()
.getResource(
"/ui-structure/gameuicomponents/HighscoreView.fxml"));
Parent root = loader.load();
HighscoreViewController controller = loader.getController();
controller.setLobbyClient(lobbyClient);
controller.refreshHighscores();
Stage stage = new Stage();
stage.initStyle(StageStyle.TRANSPARENT);
stage.setTitle("Casono Highscores");
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
stage.getIcons().add(new Image(iconPath));
Scene scene = new Scene(root);
scene.setFill(javafx.scene.paint.Color.TRANSPARENT);
stage.setScene(scene);
stage.show();
stage.setAlwaysOnTop(true);
stage.toFront();
} catch (IOException e) {
LOGGER.warn("Could not open highscore window: {}", e.getMessage());
showAlert("Could not open highscore window.");
}
}
}
@@ -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;
}
}
@@ -1,17 +1,56 @@
package ch.unibas.dmi.dbis.cs108.casono.server;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username.ChangeUsernameHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username.ChangeUsernameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username.ChangeUsernameRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetParser;
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.PlayerCallHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallParser;
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.PlayerFoldHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state.GetGameStateHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state.GetGameStateParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state.GetGameStateRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores.ClearHighscoresHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores.ClearHighscoresParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores.ClearHighscoresRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores.GetHighscoresHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores.GetHighscoresParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores.GetHighscoresRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby.CreateLobbyHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby.CreateLobbyParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby.CreateLobbyRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list.GetLobbyListHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list.GetLobbyListParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list.GetLobbyListRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status.GetLobbyStatusHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status.GetLobbyStatusParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status.GetLobbyStatusRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby.JoinLobbyHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby.JoinLobbyParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby.JoinLobbyRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game.StartGameHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game.StartGameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game.StartGameRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginRequest;
@@ -24,20 +63,18 @@ 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;
import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandlerExecutor;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter;
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;
@@ -101,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);
@@ -168,6 +180,15 @@ public class ServerApp {
commandRouter.register(
LoginRequest.class, new LoginHandler(responseDispatcher, userRegistry));
parserDispatcher.register("CHANGE_USERNAME", new ChangeUsernameParser());
commandRouter.register(
ChangeUsernameRequest.class,
new ChangeUsernameHandler(
responseDispatcher,
userRegistry,
context.lobbyManager(),
context.sessionManager()));
parserDispatcher.register("LOGOUT", new LogoutParser());
commandRouter.register(
LogoutRequest.class, new LogoutHandler(responseDispatcher, userRegistry));
@@ -192,152 +213,96 @@ public class ServerApp {
ListUsersRequest.class, new ListUsersHandler(responseDispatcher, userRegistry));
// GET_LOBBY_LIST registration
parserDispatcher.register(
"GET_LOBBY_LIST",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListParser());
parserDispatcher.register("GET_LOBBY_LIST", new GetLobbyListParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_list.GetLobbyListRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListHandler(responseDispatcher, context.lobbyManager()));
GetLobbyListRequest.class,
(CommandHandler<GetLobbyListRequest>)
new GetLobbyListHandler(responseDispatcher, context.lobbyManager()));
// GET_GAME_STATE registration
parserDispatcher.register(
"GET_GAME_STATE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateParser());
parserDispatcher.register("GET_GAME_STATE", new GetGameStateParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game
.get_game_state.GetGameStateRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateHandler(
GetGameStateRequest.class,
(CommandHandler<GetGameStateRequest>)
new GetGameStateHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// BET registration
parserDispatcher.register(
"BET",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetParser());
parserDispatcher.register("GET_HIGHSCORES", new GetHighscoresParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet
.PlayerBetRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet
.PlayerBetHandler(
GetHighscoresRequest.class,
(CommandHandler<GetHighscoresRequest>)
new GetHighscoresHandler(responseDispatcher));
parserDispatcher.register("CLEAR_HIGHSCORES", new ClearHighscoresParser());
commandRouter.register(
ClearHighscoresRequest.class,
(CommandHandler<ClearHighscoresRequest>)
new ClearHighscoresHandler(responseDispatcher));
// BET registration
parserDispatcher.register("BET", new PlayerBetParser());
commandRouter.register(
PlayerBetRequest.class,
(CommandHandler<PlayerBetRequest>)
new PlayerBetHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// RAISE registration
parserDispatcher.register(
"RAISE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseParser());
parserDispatcher.register("RAISE", new PlayerRaiseParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest
.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseHandler(
PlayerRaiseRequest.class,
(CommandHandler<PlayerRaiseRequest>)
new PlayerRaiseHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// CALL registration
parserDispatcher.register(
"CALL",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallParser());
parserDispatcher.register("CALL", new PlayerCallParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallRequest
.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallHandler(
PlayerCallRequest.class,
(CommandHandler<PlayerCallRequest>)
new PlayerCallHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// FOLD registration
parserDispatcher.register(
"FOLD",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldParser());
parserDispatcher.register("FOLD", new PlayerFoldParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest
.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldHandler(
PlayerFoldRequest.class,
(CommandHandler<PlayerFoldRequest>)
new PlayerFoldHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// GET_LOBBY_STATUS registration
parserDispatcher.register(
"GET_LOBBY_STATUS",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusParser());
parserDispatcher.register("GET_LOBBY_STATUS", new GetLobbyStatusParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusHandler(
GetLobbyStatusRequest.class,
(CommandHandler<GetLobbyStatusRequest>)
new GetLobbyStatusHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// CREATE_LOBBY registration
parserDispatcher.register(
"CREATE_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyParser());
parserDispatcher.register("CREATE_LOBBY", new CreateLobbyParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.create_lobby.CreateLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyHandler(
CreateLobbyRequest.class,
(CommandHandler<CreateLobbyRequest>)
new CreateLobbyHandler(
responseDispatcher,
context.lobbyManager(),
context.sessionManager()));
// JOIN_LOBBY registration
parserDispatcher.register(
"JOIN_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyParser());
parserDispatcher.register("JOIN_LOBBY", new JoinLobbyParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyHandler(
JoinLobbyRequest.class,
(CommandHandler<JoinLobbyRequest>)
new JoinLobbyHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// START_GAME registration
parserDispatcher.register(
"START_GAME",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameParser());
parserDispatcher.register("START_GAME", new StartGameParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameHandler(
StartGameRequest.class,
(CommandHandler<StartGameRequest>)
new StartGameHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
}
}
@@ -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");
}
}
@@ -0,0 +1,112 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username;
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.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBodyBuilder;
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.util.Optional;
import java.util.regex.Pattern;
/** Handles CHANGE_USERNAME requests for already logged-in users. */
public class ChangeUsernameHandler extends CommandHandler<ChangeUsernameRequest> {
private static final Pattern VALID_USERNAME = Pattern.compile("[a-zA-Z0-9_-]+");
private final UserRegistry userRegistry;
private final LobbyManager lobbyManager;
private final SessionManager sessionManager;
/**
* @param responseDispatcher dispatcher used for responses
* @param userRegistry registry containing all users
* @param lobbyManager lobby manager used to keep lobby/game mappings in sync
* @param sessionManager session manager used to broadcast rename events
*/
public ChangeUsernameHandler(
ResponseDispatcher responseDispatcher,
UserRegistry userRegistry,
LobbyManager lobbyManager,
SessionManager sessionManager) {
super(responseDispatcher);
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
this.sessionManager = sessionManager;
}
@Override
public void execute(ChangeUsernameRequest request) {
Optional<User> user = userRegistry.getBySessionId(request.getSessionId());
if (user.isEmpty()) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(),
"USER_NOT_LOGGED_IN",
"This session is not associated with an active user."));
return;
}
String newUsername = request.getUsername() == null ? "" : request.getUsername().trim();
if (newUsername.isEmpty() || !VALID_USERNAME.matcher(newUsername).matches()) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(),
"INVALID_USERNAME",
"Only letters, numbers, '_' and '-' are allowed."));
return;
}
User currentUser = user.get();
String oldUsername = currentUser.getName();
boolean changed = userRegistry.changeUsername(currentUser.getId(), newUsername);
if (!changed) {
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(),
"USERNAME_TAKEN",
"The requested username is already taken."));
return;
}
boolean lobbySynced =
lobbyManager == null || lobbyManager.renamePlayer(oldUsername, newUsername);
if (!lobbySynced) {
userRegistry.changeUsername(currentUser.getId(), oldUsername);
responseDispatcher.dispatch(
new ErrorResponse(
request.getContext(),
"RENAME_CONFLICT",
"Could not update username in current lobby/game state."));
return;
}
responseDispatcher.dispatch(
new ChangeUsernameResponse(
request.getContext(), currentUser.getName(), currentUser.getId()));
broadcastUsernameChanged(oldUsername, currentUser.getName());
}
private void broadcastUsernameChanged(String oldUsername, String newUsername) {
if (sessionManager == null) {
return;
}
for (Session session : sessionManager.getAllSessions()) {
RequestContext ctx = new RequestContext(session.getId(), 0);
SuccessResponse ev =
new SuccessResponse(
ctx,
new ResponseBodyBuilder()
.param("EVENT", "USERNAME_CHANGED")
.param("OLD_USERNAME", oldUsername)
.param("NEW_USERNAME", newUsername)
.build()) {};
responseDispatcher.dispatch(ev);
}
}
}
@@ -0,0 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor.RequestParameterAccessor;
/** Parses CHANGE_USERNAME requests. */
public class ChangeUsernameParser implements CommandParser<ChangeUsernameRequest> {
@Override
public ChangeUsernameRequest parse(PrimitiveRequest primitiveRequest) {
RequestParameterAccessor accessor =
new RequestParameterAccessor(primitiveRequest.parameters());
return new ChangeUsernameRequest(primitiveRequest.context(), accessor.require("USERNAME"));
}
}
@@ -0,0 +1,25 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
/** Request used to change the username of the current session user. */
public class ChangeUsernameRequest extends Request {
private final String username;
/**
* @param context request context for responses
* @param username desired new username
*/
public ChangeUsernameRequest(RequestContext context, String username) {
super(context);
this.username = username;
}
/**
* @return desired new username
*/
public String getUsername() {
return username;
}
}
@@ -0,0 +1,23 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserId;
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.ResponseBodyBuilder;
/** Response for successful username changes. */
public class ChangeUsernameResponse extends SuccessResponse {
/**
* @param context request context
* @param username current username after the rename operation
* @param id user id of renamed user
*/
public ChangeUsernameResponse(RequestContext context, String username, UserId id) {
super(
context,
new ResponseBodyBuilder()
.param("USERNAME", username)
.param("ID", id.value())
.build());
}
}
@@ -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);
}
}
@@ -8,6 +8,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore.HighscoreService;
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;
@@ -50,7 +51,11 @@ public class GetGameStateResponse extends SuccessResponse {
builder.param("CURRENT_BET", computeGlobalCurrentBet(state));
builder.param("DEALER", state.getDealerIndex());
builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex());
builder.param("WINNER", computeWinnerIndex(state, game));
int winnerIndex = computeWinnerIndex(state, game);
builder.param("WINNER", winnerIndex);
appendWinnerToHighscoresIfFinished(state, winnerIndex);
appendHighscoreEntries(builder);
appendCommunityCards(builder, state);
@@ -59,6 +64,40 @@ public class GetGameStateResponse extends SuccessResponse {
return builder.build();
}
private static void appendWinnerToHighscoresIfFinished(GameState state, int winnerIndex) {
if (winnerIndex < 0 || state.getPhase() != GamePhase.FINISHED) {
return;
}
if (!state.tryMarkWinnerPersistedForHand()) {
return;
}
Player winner = findPlayerByIndex(state, winnerIndex);
if (winner == null || winner.getId() == null || winner.getId().value() == null) {
return;
}
HighscoreService.getInstance().appendWinner(winner.getId().value());
}
private static Player findPlayerByIndex(GameState state, int winnerIndex) {
int currentIndex = 0;
for (Player player : state.getPlayers()) {
if (currentIndex == winnerIndex) {
return player;
}
currentIndex++;
}
return null;
}
private static void appendHighscoreEntries(ResponseBodyBuilder builder) {
for (String entry : HighscoreService.getInstance().readFormattedEntries()) {
builder.param("HIGHSCORE", entry);
}
}
private static int computeWinnerIndex(GameState state, GameController game) {
GamePhase phase = state.getPhase();
if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) {
@@ -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();
@@ -0,0 +1,22 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore.HighscoreService;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
/** Handler for CLEAR_HIGHSCORES. */
public class ClearHighscoresHandler extends CommandHandler<ClearHighscoresRequest> {
private final HighscoreService highscoreService;
public ClearHighscoresHandler(ResponseDispatcher responseDispatcher) {
super(responseDispatcher);
this.highscoreService = HighscoreService.getInstance();
}
@Override
public void execute(ClearHighscoresRequest request) {
highscoreService.clearAll();
responseDispatcher.dispatch(new OkResponse(request.getContext()));
}
}
@@ -0,0 +1,12 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
/** Parser for CLEAR_HIGHSCORES. */
public class ClearHighscoresParser implements CommandParser<ClearHighscoresRequest> {
@Override
public ClearHighscoresRequest parse(PrimitiveRequest primitiveRequest) {
return new ClearHighscoresRequest(primitiveRequest.context());
}
}
@@ -0,0 +1,11 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
/** Request object for CLEAR_HIGHSCORES. */
public class ClearHighscoresRequest extends Request {
public ClearHighscoresRequest(RequestContext context) {
super(context);
}
}
@@ -0,0 +1,22 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore.HighscoreService;
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;
/** Handler for GET_HIGHSCORES. */
public class GetHighscoresHandler extends CommandHandler<GetHighscoresRequest> {
private final HighscoreService highscoreService;
public GetHighscoresHandler(ResponseDispatcher responseDispatcher) {
super(responseDispatcher);
this.highscoreService = HighscoreService.getInstance();
}
@Override
public void execute(GetHighscoresRequest request) {
responseDispatcher.dispatch(
new GetHighscoresResponse(
request.getContext(), highscoreService.readFormattedEntries()));
}
}
@@ -0,0 +1,12 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParser;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
/** Parser for GET_HIGHSCORES. */
public class GetHighscoresParser implements CommandParser<GetHighscoresRequest> {
@Override
public GetHighscoresRequest parse(PrimitiveRequest primitiveRequest) {
return new GetHighscoresRequest(primitiveRequest.context());
}
}
@@ -0,0 +1,11 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
/** Request object for GET_HIGHSCORES. */
public class GetHighscoresRequest extends Request {
public GetHighscoresRequest(RequestContext context) {
super(context);
}
}
@@ -0,0 +1,23 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores;
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 java.util.List;
/** Success response containing repeated HIGHSCORE parameters. */
public class GetHighscoresResponse extends SuccessResponse {
public GetHighscoresResponse(RequestContext context, List<String> entries) {
super(
context,
ResponseBody.builder()
.block(
"HIGHSCORES",
block -> {
for (String entry : entries) {
block.param("HIGHSCORE", entry);
}
})
.build());
}
}
@@ -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,10 +5,12 @@ 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;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import java.util.Optional;
public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
private final UserRegistry userRegistry;
@@ -39,6 +41,10 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
@Override
public void execute(SendMessageRequest request) {
Message message = request.getMessage();
Optional<User> senderUser = userRegistry.getBySessionId(request.getSessionId());
if (senderUser.isPresent()) {
message.sender = senderUser.get().getName();
}
broadcast(request, message);
OkResponse response = new OkResponse(request.getContext());
responseDispatcher.dispatch(response);
@@ -10,6 +10,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Deck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.GameEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandEvaluator;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandRank;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
@@ -61,6 +62,35 @@ public class GameController {
engine.getState().addPlayer(name, chips);
}
/**
* Renames a player id in the controller list and underlying game state.
*
* @param oldId old player id
* @param newId new player id
* @return true if rename succeeded
*/
public boolean renamePlayer(PlayerId oldId, PlayerId newId) {
if (oldId == null || newId == null) {
return false;
}
if (oldId.equals(newId)) {
return true;
}
int idx = players.indexOf(oldId);
if (idx < 0 || players.contains(newId)) {
return false;
}
boolean stateRenamed = engine.getState().renamePlayerId(oldId, newId);
if (!stateRenamed) {
return false;
}
players.set(idx, newId);
return true;
}
/**
* Initializes a new hand by preparing the deck, setting the phase to PREFLOP, rotating the
* dealer, dealing hole cards, posting blinds, and setting the first active player.
@@ -154,9 +184,22 @@ public class GameController {
*
* @param playerId The ID of the player who is folding.
*/
public void playerFold(PlayerId playerId) {
// engine.processAction(new FoldAction(PlayerId.of(playerId)));
public PlayerId playerFold(PlayerId playerId) {
engine.processAction(new FoldAction(playerId));
GameState state = engine.getState();
if (state.countNonFoldedPlayers() == 1) {
for (Player p : state.getPlayers()) {
if (!p.isFolded()) {
return p.getId();
}
}
}
return null;
}
/**
@@ -1,6 +1,9 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.Action;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.ActionType;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
@@ -80,12 +83,38 @@ public class GameEngine {
*/
public void handleAction(GameState state, Action action) {
if (action.getType() != ActionType.BLIND) {
Player currentPlayer = state.getCurrentPlayer();
PlayerId actingPlayerId = action.getPlayerId();
if (currentPlayer == null || actingPlayerId == null) {
throw new RuntimeException("Invalid turn state");
}
if (!state.isAllowOutOfTurn() && !currentPlayer.getId().equals(actingPlayerId)) {
throw new RuntimeException("Not your turn");
}
Player actingPlayer = state.getPlayer(actingPlayerId);
if (actingPlayer.isFolded()) {
throw new RuntimeException("Player already folded");
}
if (actingPlayer.isAllIn()) {
throw new RuntimeException("Player is all-in");
}
}
// 1.Check the rules
ruleEngine.validate(state, action);
// 2. Perform action
action.execute(state);
if (action.getType() != ActionType.BLIND) {
state.markActedThisRound(action.getPlayerId());
}
// 3. Next turn
turnManager.nextPlayer(state);
@@ -4,7 +4,6 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Card;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Deck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerStatus;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import java.util.ArrayList;
@@ -27,6 +26,13 @@ public class RoundManager {
public static final int SMALL_BLIND = 100;
public static final int BIG_BLIND = 200;
/**
* Starts a new hand by resetting the game state, ensuring a deck is available, dealing hole
* cards to players, posting blinds, and setting the first player to act for the preflop phase.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to
*/
public void startNewHand(GameState state) {
// Use GameState's canonical reset
state.startNewHand();
@@ -44,12 +50,19 @@ public class RoundManager {
state.setCurrentPlayerToPreflopFirstToAct();
}
/**
* Checks if the current betting round is finished and advances the game phase if necessary.
*
* @param state The GameState object representing the current state of the game, which may be
* modified to advance the phase or end the hand.
*/
public void progressIfNeeded(GameState state) {
if (state.getPhase() == null) {
state.setPhase(GamePhase.PREFLOP);
}
if (state.countNonFoldedPlayers() == 1) {
state.setHandActive(false);
state.setPhase(GamePhase.FINISHED);
return;
}
@@ -59,27 +72,51 @@ public class RoundManager {
}
}
/**
* Determines if the current betting round is finished by checking if all active players have
* met the current bet or are all-in and by handling special cases for preflop betting rounds.
*
* @param state The GameState object representing the current state of the game, which is used
* to evaluate the betting round status.
* @return true if the betting round is finished and the game can progress to the next phase,
* false otherwise.
*/
private boolean isBettingRoundFinished(GameState state) {
int target = state.getTableState().getCurrentBet();
// A betting round only ends after every active player had at least one chance to act.
return allActivePlayersActedThisRound(state);
}
/**
* Helper method to check if all active players have acted in the current betting round. This is
* used to handle the special case of preflop rounds where no bets have been made yet, but
* players still need to have the opportunity to act.
*
* @param state The GameState object representing the current state of the game, which is used
* to check if all active players have acted in the current round.
* @return true if all active players have acted in the current round, false if there are still
* active players who have not acted yet.
*/
private boolean allActivePlayersActedThisRound(GameState state) {
for (Player p : state.getPlayers()) {
if (p == null) {
if (p == null || p.isFolded() || p.isAllIn()) {
continue;
}
// be tolerant if codebase mixes status + boolean flags
if (p.getStatus() == PlayerStatus.FOLDED || p.isFolded()) {
continue;
}
int bet = state.getCurrentBet(p.getId());
if (bet < target && !p.isAllIn()) {
if (!state.hasActedThisRound(p.getId())) {
return false;
}
}
return true;
}
/**
* Advances the game phase to the next stage (FLOP, TURN, RIVER, SHOWDOWN) based on the current
* phase.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to advance the phase and deal community cards as needed when progressing to the
* next stage of the hand.
*/
private void advancePhase(GameState state) {
switch (state.getPhase()) {
case PREFLOP -> dealFlop(state);
@@ -93,6 +130,12 @@ public class RoundManager {
}
}
/**
* Ensures that a deck of cards is available in the game state.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to include a new shuffled deck if one does not already exist.
*/
private void ensureDeck(GameState state) {
Deck deck = state.getDeck();
if (deck == null) {
@@ -102,6 +145,13 @@ public class RoundManager {
}
}
/**
* Deals hole cards to each player in the game state by drawing two cards from the deck for each
* player and assigning them as their hole cards.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to assign hole cards to each active player.
*/
private void dealHoleCards(GameState state) {
Deck deck = state.getDeck();
@@ -165,6 +215,14 @@ public class RoundManager {
state.getTableState().setCurrentBet(BIG_BLIND);
}
/**
* Deals the flop by drawing three community cards from the deck and adding them to the game
* state, then setting the game phase to FLOP and resetting bets for the new betting round.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to add three community cards for the flop, set the phase to FLOP, and reset bets
* for the new betting round.
*/
private void dealFlop(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
@@ -180,6 +238,14 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct();
}
/**
* Deals the turn by drawing one community card from the deck and adding it to the game state,
* then setting the game phase to TURN and resetting bets for the new betting round.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to add one community card for the turn, set the phase to TURN, and reset bets
* for the new betting round.
*/
private void dealTurn(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
@@ -193,6 +259,14 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct();
}
/**
* Deals the river by drawing one community card from the deck and adding it to the game state,
* then setting the game phase to RIVER and resetting bets for the new betting round.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to add one community card for the river, set the phase to RIVER, and reset bets
* for the new betting round.
*/
private void dealRiver(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
@@ -206,6 +280,12 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct();
}
/**
* Handles the showdown phase by setting the game phase to SHOWDOWN.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to set the phase to SHOWDOWN.
*/
private void showdown(GameState state) {
state.setPhase(GamePhase.SHOWDOWN);
@@ -49,6 +49,16 @@ public class Player {
return id;
}
/**
* Updates the player's id. This is used when a username change is propagated into an already
* running game.
*
* @param id new player id
*/
public void setId(PlayerId id) {
this.id = id;
}
/**
* Returns the display name of the player. Currently identical to the player ID.
*
@@ -7,8 +7,10 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The GameState class encapsulates the entire state of a poker game at any given moment.
@@ -33,11 +35,13 @@ public class GameState {
private int currentPlayerIndex;
private boolean handActive;
private boolean allowOutOfTurn;
private boolean winnerPersistedForHand;
private GamePhase phase;
private int dealerIndex;
private final Map<PlayerId, Integer> currentBets = new HashMap<>();
private final Map<PlayerId, Integer> playerBetCommitments = new HashMap<>();
private final Set<PlayerId> actedThisRound = new HashSet<>();
private final Map<PlayerId, List<Card>> holeCards = new HashMap<>();
private final List<Card> communityCards = new ArrayList<>();
@@ -46,7 +50,11 @@ public class GameState {
private static final int DEALER_OFFSET = 3;
// Getters
/**
* Returns the players in seating/turn order.
*
* @return a collection of players in the order they are seated/act.
*/
public Collection<Player> getPlayers() {
List<Player> ordered = new ArrayList<>(playerOrder.size());
for (PlayerId id : playerOrder) {
@@ -58,57 +66,123 @@ public class GameState {
return ordered;
}
/**
* Returns the current player whose turn it is to act.
*
* @return the current player, or null if there are no players or the index is out of bounds.
*/
public Player getCurrentPlayer() {
PlayerId id = playerOrder.get(currentPlayerIndex);
return players.get(id);
}
/**
* Returns the index of the current player in the player order list.
*
* @return the index of the current player, or 0 if there are no players.
*/
public int getCurrentPlayerIndex() {
return currentPlayerIndex;
}
/**
* Returns whether a hand is currently active (i.e., in progress).
*
* @return true if a hand is active, false otherwise.
*/
public boolean isHandActive() {
return handActive;
}
/**
* Returns the current phase of the game (e.g., PREFLOP, FLOP, TURN, RIVER).
*
* @return the current game phase.
*/
public GamePhase getPhase() {
return phase;
}
/**
* Returns the current state of the table, including information such as the current bet and pot
* size.
*
* @return the current table state.
*/
public TableState getTableState() {
return tableState;
}
/**
* Returns the current pot, which includes the total amount of chips in the pot and the
* contributions from each player.
*
* @return the current pot.
*/
public Pot getPot() {
return pot;
}
/**
* Returns the current deck of cards being used in the game.
*
* @return the current deck of cards.
*/
public Deck getDeck() {
return deck;
}
/**
* Returns the list of community cards currently on the table.
*
* @return a list of community cards, which may be empty if no cards have been dealt yet.
*/
public List<Card> getCommunityCards() {
return communityCards;
}
/** Always returns a mutable list (never null). */
/**
* Returns the hole cards for the specified player. If the player does not have hole cards yet,
* an empty list is returned and stored in the map for future reference.
*
* @param playerId the ID of the player whose hole cards are being requested.
* @return a list of hole cards for the specified player, which may be empty if the player has
* not been dealt cards yet.
*/
public List<Card> getHoleCards(PlayerId playerId) {
return holeCards.computeIfAbsent(playerId, k -> new ArrayList<>());
}
/**
* Returns the index of the dealer in the player order list.
*
* @return the index of the dealer, or 0 if there are no players.
*/
public int getDealerIndex() {
return dealerIndex;
}
/**
* Returns a map of player IDs to their respective hole cards. Each player's hole cards are
* represented as a list of Card objects. If a player does not have hole cards yet, they will be
* associated with an empty list.
*
* @return a map where the key is the player's ID and the value is a list of their hole cards.
*/
public Map<PlayerId, List<Card>> getPlayerCards() {
return holeCards;
}
/**
* Returns the number of players currently in the game, based on the size of the player order
* list.
*
* @return the number of players in the game.
*/
public int getPlayerCount() {
return players.size();
}
// Setters / Mutators
public void setCurrentPlayerIndex(int index) {
if (playerOrder.isEmpty()) {
this.currentPlayerIndex = 0;
@@ -117,6 +191,10 @@ public class GameState {
this.currentPlayerIndex = index;
}
/**
* Sets the current player index to the first player who should act preflop, which is determined
* based on the dealer index and the number of players.
*/
public void setCurrentPlayerToPreflopFirstToAct() {
int size = playerOrder.size();
if (size == 0) {
@@ -127,11 +205,16 @@ public class GameState {
// Heads-up: dealer (small blind) acts first preflop.
int first = (size == 2) ? dealerIndex : (dealerIndex + DEALER_OFFSET) % size;
currentPlayerIndex = first;
resetRoundActions();
if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer();
}
}
/**
* Sets the current player index to the first player who should act postflop, which is the
* player immediately to the left of the dealer (i.e., dealerIndex + 1).
*/
public void setCurrentPlayerToPostflopFirstToAct() {
int size = playerOrder.size();
if (size == 0) {
@@ -141,27 +224,86 @@ public class GameState {
int first = (dealerIndex + 1) % size;
currentPlayerIndex = first;
resetRoundActions();
if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer();
}
}
/**
* Marks the specified player as having acted in the current round.
*
* @param playerId the ID of the player to mark as having acted this round.
*/
public void markActedThisRound(PlayerId playerId) {
if (playerId != null) {
actedThisRound.add(playerId);
}
}
/**
* Checks if the specified player has already acted in the current round by checking if their ID
* is present in the set of players who have acted this round.
*
* @param playerId the ID of the player to check for having acted this round.
* @return true if the player has acted this round, false otherwise.
*/
public boolean hasActedThisRound(PlayerId playerId) {
return playerId != null && actedThisRound.contains(playerId);
}
/**
* Resets the tracking of which players have acted this round by clearing the set of player IDs.
*/
public void resetRoundActions() {
actedThisRound.clear();
}
/**
* Sets whether a hand is currently active (i.e., in progress).
*
* @param handActive a boolean value indicating whether a hand is active (true) or not (false).
*/
public void setHandActive(boolean handActive) {
this.handActive = handActive;
}
/**
* Sets the current phase of the game (e.g., PREFLOP, FLOP, TURN, RIVER).
*
* @param phase the GamePhase to set as the current phase of the game.
*/
public void setPhase(GamePhase phase) {
this.phase = phase;
}
/**
* Sets the index of the dealer in the player order list.
*
* @param dealerIndex the index to set as the dealer index, which should be within the bounds of
* the player order list if it is not empty.
*/
public void setDealerIndex(int dealerIndex) {
this.dealerIndex = dealerIndex;
}
/**
* Sets the current deck of cards being used in the game.
*
* @param deck the Deck object to set as the current deck of cards for the game. This should be
* a valid Deck instance that can be used for dealing cards during the game.
*/
public void setDeck(Deck deck) {
this.deck = deck;
}
/**
* Adds a new player to the game with the specified ID and initial chip count.
*
* @param id the PlayerId of the new player to add to the game.
* @param chips the initial number of chips the player has, which can be used for betting during
* the game.
*/
public void addPlayer(PlayerId id, int chips) {
Player player = new Player(id, chips);
@@ -174,18 +316,87 @@ public class GameState {
holeCards.computeIfAbsent(id, k -> new ArrayList<>());
}
// Betting
/**
* Renames a player id across all game-state structures.
*
* @param oldId existing player id
* @param newId new player id
* @return true if the rename was applied, false otherwise
*/
public synchronized boolean renamePlayerId(PlayerId oldId, PlayerId newId) {
if (oldId == null || newId == null) {
return false;
}
if (!players.containsKey(oldId)) {
return false;
}
if (oldId.equals(newId)) {
return true;
}
if (players.containsKey(newId)) {
return false;
}
Player player = players.remove(oldId);
if (player == null) {
return false;
}
player.setId(newId);
players.put(newId, player);
int idx = playerOrder.indexOf(oldId);
if (idx >= 0) {
playerOrder.set(idx, newId);
}
moveMapEntry(currentBets, oldId, newId, 0);
moveMapEntry(playerBetCommitments, oldId, newId, 0);
moveMapEntry(holeCards, oldId, newId, new ArrayList<>());
return true;
}
/**
* Helper method to move an entry in a map from an old key to a new key, with a fallback value
* if the old key is not present.
*
* @param map the map in which to move the entry, where the key is a PlayerId and the value is
* of type T.
* @param oldId the existing PlayerId key that is being renamed.
* @param newId the new PlayerId key to which the entry should be moved.
* @param fallback the value to use if the oldId is not present in the map.
* @param <T> the type of the values in the map, which can be any type that is used for
* player-related data in the game state.
*/
private <T> void moveMapEntry(
Map<PlayerId, T> map, PlayerId oldId, PlayerId newId, T fallback) {
T value = map.remove(oldId);
map.put(newId, value != null ? value : fallback);
}
// BETTING
/**
* Returns the current bet amount for the specified player.
*
* @param playerId the ID of the player whose current bet is being requested.
* @return the current bet amount for the specified player, or 0 if the player does not have a
* current bet.
*/
public int getCurrentBet(PlayerId playerId) {
return currentBets.getOrDefault(playerId, 0);
}
/**
* Sets the current bet amount for the specified player.
*
* @param playerId the ID of the player whose current bet is being set.
* @param amount the new bet amount to set for the specified player.
*/
public void setCurrentBet(PlayerId playerId, int amount) {
currentBets.put(playerId, amount);
}
/**
* Reset bets to 0 for ALL players (do not clear the map). Also resets table current bet to 0.
*/
/** Reset bets to 0 for ALL players (do not clear the map). */
public void resetBets() {
for (PlayerId id : playerOrder) {
currentBets.put(id, 0);
@@ -193,10 +404,20 @@ public class GameState {
tableState.setCurrentBet(0);
}
/**
* Adds the specified amount to the pot.
*
* @param amount the amount to add to the pot.
*/
public void addToPot(int amount) {
pot.add(amount);
}
/**
* Returns whether out-of-turn actions are allowed in the current game state.
*
* @return true if out-of-turn actions are allowed, false otherwise.
*/
public boolean isAllowOutOfTurn() {
return allowOutOfTurn;
}
@@ -205,7 +426,13 @@ public class GameState {
this.allowOutOfTurn = allowOutOfTurn;
}
// Player helpers
/**
* Retrieves the Player object associated with the given PlayerId.
*
* @param id the PlayerId of the player to retrieve.
* @return the Player object associated with the specified PlayerId, or a RuntimeException if
* the player is not found.
*/
public Player getPlayer(PlayerId id) {
Player player = players.get(id);
if (player == null) {
@@ -215,7 +442,13 @@ public class GameState {
return player;
}
// Bet commitments
/**
* Retrieves the current bet commitment for the specified player.
*
* @param playerId the ID of the player whose current bet commitment is being requested.
* @return the current bet commitment for the specified player, or 0 if the player does not have
* a bet commitment.
*/
public int getCurrentBetCommitment(PlayerId playerId) {
return playerBetCommitments.getOrDefault(playerId, 0);
}
@@ -224,7 +457,7 @@ public class GameState {
playerBetCommitments.put(playerId, amount);
}
// Cards
// CARDS
/**
* Gives (overwrites) the two hole cards for the given player. Ensures stable list identity
@@ -237,15 +470,21 @@ public class GameState {
cards.add(c2);
}
/**
* Adds a community card to the list of community cards on the table.
*
* @param card the Card object representing the community card to be added to the table.
*/
public void addCommunityCard(Card card) {
communityCards.add(card);
}
/** Resets the community cards by clearing the list of community cards on the table. */
public void resetCommunityCards() {
communityCards.clear();
}
// Turn / dealer management
/** Advances the current player index to the next player who can act. */
public void nextPlayer() {
if (playerOrder.isEmpty()) {
currentPlayerIndex = 0;
@@ -263,6 +502,14 @@ public class GameState {
} while (currentPlayerIndex != start);
}
/**
* Checks if the player at the specified index in the player order list is able to take an
* action.
*
* @param index the index of the player in the player order list to check for actionability.
* @return true if the player at the specified index can act (i.e., is not folded and not
* all-in), false otherwise.
*/
private boolean canPlayerAct(int index) {
if (index < 0 || index >= playerOrder.size()) {
return false;
@@ -277,12 +524,19 @@ public class GameState {
dealerIndex = (dealerIndex + 1) % playerOrder.size();
}
/**
* Returns the Player object representing the current dealer based on the dealer index in the
* player order list.
*
* @return the Player object representing the current dealer, or null if there are no players or
* the dealer index is out of bounds.
*/
public Player getDealer() {
PlayerId id = playerOrder.get(dealerIndex);
return players.get(id);
}
// Hand reset / lifecycle
// HAND RESET / LIFECYCLE
/**
* Starts a new hand by resetting the game state for the next round of poker.
@@ -298,6 +552,7 @@ public class GameState {
*/
public void startNewHand() {
handActive = true;
resetWinnerPersistedForHand();
phase = GamePhase.PREFLOP;
pot = new Pot();
@@ -322,10 +577,35 @@ 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);
}
/**
* Checks if the specified player has folded by retrieving their Player object and checking
* their folded status.
*
* @param playerId the ID of the player to check for folded status.
* @return true if the player has folded, false otherwise.
*/
public boolean isFolded(PlayerId playerId) {
return getPlayer(playerId).isFolded();
}
@@ -0,0 +1,107 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/** Stores and reads persistent highscore entries in timestamp + winner format. */
public final class HighscoreService {
private static final HighscoreService INSTANCE = new HighscoreService();
private static final DateTimeFormatter DISPLAY_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
private static final int MAX_RETURNED_ENTRIES = 100;
private static final String LINE_SEPARATOR = "\t";
private final Path storagePath;
private HighscoreService() {
this.storagePath =
Path.of(System.getProperty("user.home"), ".casono", "highscores", "winners.log");
}
public static HighscoreService getInstance() {
return INSTANCE;
}
public synchronized void appendWinner(String winnerName) {
if (winnerName == null) {
return;
}
String sanitized = sanitizeName(winnerName);
if (sanitized.isEmpty()) {
return;
}
String line = Instant.now() + LINE_SEPARATOR + sanitized + System.lineSeparator();
try {
Files.createDirectories(storagePath.getParent());
Files.writeString(
storagePath,
line,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
} catch (IOException ignored) {
// Highscore persistence must never break the game state response path.
}
}
public synchronized List<String> readFormattedEntries() {
if (!Files.exists(storagePath)) {
return List.of();
}
List<String> lines;
try {
lines = Files.readAllLines(storagePath, StandardCharsets.UTF_8);
} catch (IOException ignored) {
return List.of();
}
List<String> formatted = new ArrayList<>();
for (String raw : lines) {
if (raw == null || raw.isBlank()) {
continue;
}
String[] parts = raw.split(LINE_SEPARATOR, 2);
if (parts.length < 2) {
continue;
}
try {
Instant ts = Instant.parse(parts[0].trim());
String display = DISPLAY_FORMATTER.format(ts) + " | " + parts[1].trim();
formatted.add(display);
} catch (Exception ignored) {
// Skip malformed lines and keep all valid entries.
}
}
int from = Math.max(0, formatted.size() - MAX_RETURNED_ENTRIES);
return new ArrayList<>(formatted.subList(from, formatted.size()));
}
public synchronized void clearAll() {
try {
Files.deleteIfExists(storagePath);
} catch (IOException ignored) {
// Clearing highscores must not break request handling.
}
}
private static String sanitizeName(String winnerName) {
return winnerName.trim().replace("\n", " ").replace("\r", " ").replace("\t", " ");
}
}
@@ -70,6 +70,30 @@ public class Lobby {
return playerNames.remove(playerName);
}
/**
* Renames a player in this lobby's player list.
*
* @param oldName old username
* @param newName new username
* @return true if renamed successfully
*/
public boolean renamePlayer(String oldName, String newName) {
if (oldName == null || newName == null) {
return false;
}
synchronized (playerNames) {
if (oldName.equals(newName)) {
return playerNames.contains(oldName);
}
int idx = playerNames.indexOf(oldName);
if (idx < 0 || playerNames.contains(newName)) {
return false;
}
playerNames.set(idx, newName);
return true;
}
}
public void initGame(GameController controller) {
this.gameController = controller;
}
@@ -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);
}
}
}
}
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby.AddResult;
import java.time.Duration;
import java.time.Instant;
@@ -170,6 +171,56 @@ public class LobbyManager {
}
}
/**
* Renames a player across lobby mapping, lobby player list and running game ids.
*
* @param oldUsername old username
* @param newUsername new username
* @return true if rename was applied
*/
public synchronized boolean renamePlayer(String oldUsername, String newUsername) {
if (oldUsername == null || newUsername == null) {
return false;
}
if (oldUsername.equals(newUsername)) {
return true;
}
LobbyId lobbyId = playerToLobby.get(oldUsername);
if (lobbyId == null) {
return true;
}
if (playerToLobby.containsKey(newUsername)) {
return false;
}
Lobby lobby = activeLobbies.get(lobbyId);
if (lobby == null) {
playerToLobby.remove(oldUsername);
return true;
}
boolean lobbyRenamed = lobby.renamePlayer(oldUsername, newUsername);
if (!lobbyRenamed) {
return false;
}
if (lobby.getGameController() != null) {
boolean gameRenamed =
lobby.getGameController()
.renamePlayer(PlayerId.of(oldUsername), PlayerId.of(newUsername));
if (!gameRenamed) {
// Best-effort rollback to keep structures consistent.
lobby.renamePlayer(newUsername, oldUsername);
return false;
}
}
playerToLobby.remove(oldUsername);
playerToLobby.put(newUsername, lobbyId);
return true;
}
/**
* Apply the given action to every player username in the lobby identified by {@code lobbyId}.
* This is a small helper that keeps iteration logic centralized and avoids leaking internal
@@ -200,6 +200,10 @@ public class UserRegistry {
return false;
}
if (user.getName().equals(newName)) {
return true;
}
if (byName.containsKey(newName)) {
return false;
}
@@ -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>
@@ -81,25 +81,32 @@
</GridPane.margin>
</Button>
<!-- Username input and login button -->
<!-- <HBox alignment="CENTER_LEFT" spacing="10"-->
<!-- GridPane.rowIndex="0"-->
<!-- GridPane.columnIndex="0"-->
<!-- GridPane.halignment="LEFT"-->
<!-- GridPane.valignment="TOP">-->
<!-- <GridPane.margin>-->
<!-- &lt;!&ndash; place below the 'CREATE A LOBBY' button &ndash;&gt;-->
<!-- <Insets top="100" left="20" />-->
<!-- </GridPane.margin>-->
<!-- <TextField fx:id="usernameField"-->
<!-- promptText="Username"-->
<!-- styleClass="gray-input-field"-->
<!-- maxWidth="180" />-->
<!-- <Button text="Login"-->
<!-- fx:id="loginButton"-->
<!-- onAction="#handleLoginButton"-->
<!-- styleClass="button-create-lobby" />-->
<!-- </HBox>-->
<!-- Username input + change name, with highscores directly below -->
<VBox alignment="TOP_LEFT" spacing="10"
GridPane.rowIndex="0"
GridPane.columnIndex="0"
GridPane.halignment="LEFT"
GridPane.valignment="TOP">
<GridPane.margin>
<Insets top="140" left="20" />
</GridPane.margin>
<HBox alignment="CENTER_LEFT" spacing="10">
<TextField fx:id="usernameField"
promptText="Username"
styleClass="gray-input-field"
maxWidth="180" />
<Button text="Apply Name"
fx:id="loginButton"
onAction="#handleLoginButton"
styleClass="button-create-lobby" />
</HBox>
<Button text="HIGHSCORES"
styleClass="button-create-lobby"
fx:id="highscoreButton"
onAction="#handleOpenHighscores" />
</VBox>
<VBox fx:id="casinoTable"
alignment="CENTER"
@@ -0,0 +1,139 @@
.highscore-root {
-fx-background-color: rgba(13, 158, 59, 0.96);
-fx-background-radius: 16;
-fx-border-radius: 16;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
-fx-border-width: 3;
}
.highscore-title {
-fx-text-fill: #ffffff;
-fx-font-size: 24px;
-fx-font-weight: bold;
-fx-effect: dropshadow(one-pass-box, #000000, 0, 0, 2, 2);
}
.highscore-status {
-fx-text-fill: #ffffff;
-fx-font-size: 13px;
}
.list-view {
-fx-background-color: #114d26;
-fx-control-inner-background: #114d26;
-fx-background-insets: 0;
-fx-background-radius: 12;
-fx-border-radius: 12;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
-fx-border-width: 2;
}
.list-view .list-cell {
-fx-background-color: transparent;
-fx-text-fill: #ffe89a;
-fx-font-family: "Courier New";
-fx-font-size: 13px;
}
.list-view .list-cell:filled {
-fx-background-color: rgba(0, 0, 0, 0.14);
}
.list-view .list-cell:filled:hover {
-fx-background-color: rgba(0, 0, 0, 0.24);
}
.list-view .list-cell:filled:selected,
.list-view .list-cell:filled:selected:focused {
-fx-background-color: rgba(0, 0, 0, 0.35);
-fx-text-fill: #ffffff;
}
.gray-button,
.yellow-button,
.red-button {
-fx-background-radius: 10;
-fx-border-radius: 10;
-fx-font-family: "Courier New";
-fx-font-weight: bold;
-fx-border-width: 2;
-fx-padding: 6 12;
-fx-cursor: hand;
}
.gray-button {
-fx-background-color: #333333;
-fx-border-color: #666666;
-fx-text-fill: #cccccc;
}
.yellow-button {
-fx-background-color: #b8860b;
-fx-border-color: #ffd700;
-fx-text-fill: #ffffff;
}
.red-button {
-fx-background-color: #8b0000;
-fx-border-color: #ff4444;
-fx-text-fill: #ffffff;
}
.highscore-root {
-fx-background-color: #0d9e3b;
-fx-background-radius: 16;
-fx-border-radius: 16;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
-fx-border-width: 3;
}
.highscore-title {
-fx-text-fill: #ffffff;
-fx-font-size: 22px;
-fx-font-weight: bold;
}
.highscore-subtitle {
-fx-text-fill: #f2f2f2;
-fx-font-size: 13px;
}
.highscore-text-area {
-fx-control-inner-background: #1b1b1b;
-fx-text-fill: #ffffff;
-fx-font-family: "Courier New";
-fx-font-size: 14px;
-fx-border-color: #666666;
-fx-border-width: 2;
-fx-border-radius: 10;
-fx-background-radius: 10;
}
.gray-button,
.green-button,
.red-button {
-fx-background-radius: 10;
-fx-border-radius: 10;
-fx-font-family: "Courier New";
-fx-font-weight: bold;
-fx-border-width: 2;
-fx-padding: 6 14;
}
.green-button {
-fx-background-color: #1f7d33;
-fx-border-color: #35c759;
-fx-text-fill: #ffffff;
}
.red-button {
-fx-background-color: #8b0000;
-fx-border-color: #ff4444;
-fx-text-fill: #ffffff;
}
.gray-button {
-fx-background-color: #333333;
-fx-border-color: #666666;
-fx-text-fill: #cccccc;
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?>
<?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"
fx:controller="ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.HighscoreViewController"
spacing="12"
styleClass="highscore-root"
onMousePressed="#onPopupPressed"
onMouseDragged="#onPopupDragged"
stylesheets="@HighscoreView.css">
<padding>
<Insets top="14" right="14" bottom="14" left="14"/>
</padding>
<Label text="Highscores" styleClass="highscore-title"/>
<ListView fx:id="highscoreList" prefHeight="320" VBox.vgrow="ALWAYS"/>
<Label fx:id="statusLabel" text="Ready." styleClass="highscore-status"/>
<HBox spacing="10">
<Button text="Refresh" onAction="#refreshHighscores" styleClass="gray-button" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
<Button text="Clear" onAction="#clearHighscores" styleClass="yellow-button" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
<Button text="Close" onAction="#closeWindow" styleClass="red-button" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
</HBox>
</VBox>
@@ -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"
@@ -19,7 +18,7 @@
<!-- Placeholder buttons -->
<Button text="SETTINGS" styleClass="gray-button" />
<Button text="INFO" styleClass="gray-button" />
<Button text="HIGHSCORE" onAction="#onHighscoreButtonClick" styleClass="gray-button" />
<!-- Buten for the Casono Browser -->
<Button text="HELP" onAction="#onBrowserButtonClick" styleClass="gray-button" />
@@ -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,8 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
import static org.junit.jupiter.api.Assertions.*;
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.*;
@@ -10,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);
@@ -37,6 +37,127 @@ public class GameControllerTest {
private static final Logger LOGGER = Logger.getLogger(GameControllerTest.class.getName());
/**
* Helper method to retrieve the current player's ID from the game state.
*
* @param game The GameController instance from which to retrieve the current player's ID.
* @return The PlayerId of the current player in the game.
*/
private static PlayerId currentPlayerId(GameController game) {
return game.getState().getCurrentPlayer().getId();
}
/**
* Helper method to simulate a call action for the current player in the game.
*
* @param game The GameController instance on which to perform the call action for the current
* player.
*/
private static void callCurrent(GameController game) {
game.playerCall(currentPlayerId(game));
}
/**
* Helper method to simulate a fold action for the current player in the game.
*
* @param game The GameController instance on which to perform the fold action for the current
* player.
*/
private static void foldCurrent(GameController game) {
game.playerFold(currentPlayerId(game));
}
/**
* Verifies that the preflop phase ends once all active (non-folded) players have completed
* their required actions for the current betting round.
*/
@Test
public void testPreflopEndsAfterOneActionPerActivePlayer() {
GameState state = new GameState();
GameEngine engine =
new GameEngine(
state,
new RuleEngine(new ArrayList<>()),
new RoundManager(),
new TurnManager());
GameController game = new GameController(engine);
game.addPlayer(PlayerId.of("P1"), 10000);
game.addPlayer(PlayerId.of("P2"), 10000);
game.addPlayer(PlayerId.of("P3"), 10000);
game.addPlayer(PlayerId.of("P4"), 10000);
game.startGame();
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
assertEquals(0, game.getCommunityCards().size());
callCurrent(game);
callCurrent(game);
callCurrent(game);
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
assertEquals(0, game.getCommunityCards().size());
callCurrent(game);
assertEquals(GamePhase.FLOP, game.getState().getPhase());
assertEquals(3, game.getCommunityCards().size());
callCurrent(game);
callCurrent(game);
callCurrent(game);
callCurrent(game);
assertEquals(GamePhase.TURN, game.getState().getPhase());
assertEquals(4, game.getCommunityCards().size());
callCurrent(game);
callCurrent(game);
callCurrent(game);
callCurrent(game);
assertEquals(GamePhase.RIVER, game.getState().getPhase());
assertEquals(5, game.getCommunityCards().size());
}
/**
* Ensures that during the preflop phase, folded players are excluded from progression checks
* and do not block phase advancement.
*/
@Test
public void testPreflopCountsOnlyNonFoldedPlayersForProgress() {
GameState state = new GameState();
GameEngine engine =
new GameEngine(
state,
new RuleEngine(new ArrayList<>()),
new RoundManager(),
new TurnManager());
GameController game = new GameController(engine);
game.addPlayer(PlayerId.of("A"), 10000);
game.addPlayer(PlayerId.of("B"), 10000);
game.addPlayer(PlayerId.of("C"), 10000);
game.addPlayer(PlayerId.of("D"), 10000);
game.startGame();
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
callCurrent(game);
foldCurrent(game);
callCurrent(game);
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
callCurrent(game);
assertEquals(GamePhase.FLOP, game.getState().getPhase());
assertEquals(3, game.getCommunityCards().size());
}
/**
* This test simulates a specific game scenario where Julian is expected to win with a Two Pair
* hand. It sets up a fixed deck to ensure that the desired cards are dealt to the players and
@@ -83,11 +204,14 @@ public class GameControllerTest {
}
}
game.playerCall(PlayerId.of("Julian"));
callCurrent(game);
game.playerFold(PlayerId.of("Mathis"));
game.playerCall(PlayerId.of("Jona"));
callCurrent(game);
game.playerRaise(PlayerId.of("Lars"), 1200);
// New street model: once all non-folded players acted, preflop is complete.
assertEquals(GamePhase.FLOP, game.getState().getPhase());
List<Card> board = game.getCommunityCards();
assertTrue(board.size() >= 3 && board.size() <= 5);
@@ -170,10 +294,10 @@ public class GameControllerTest {
int potBefore = game.getState().getPot().getAmount();
game.playerRaise(PlayerId.of("Lars"), 1200);
game.playerCall(PlayerId.of("Julian"));
game.playerFold(PlayerId.of("Mathis"));
game.playerCall(PlayerId.of("Jona"));
game.playerRaise(currentPlayerId(game), 1200);
callCurrent(game);
foldCurrent(game);
callCurrent(game);
System.out.println("Pot after actions: " + game.getState().getPot().getAmount());
@@ -183,11 +307,15 @@ public class GameControllerTest {
assertEquals(3, game.getCommunityCards().size(), "The flop must have 3 cards");
game.playerCall(PlayerId.of("Julian"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 4; i++) {
callCurrent(game);
}
assertEquals(4, game.getCommunityCards().size(), "A turn must result in 4 cards");
game.playerCall(PlayerId.of("Jona"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size(), "The river must consist of 5 cards");
@@ -278,13 +406,12 @@ public class GameControllerTest {
game.playerCall(PlayerId.of("Jona"));
game.playerRaise(PlayerId.of("Lars"), 1200);
game.playerCall(PlayerId.of("Julian"));
game.playerCall(PlayerId.of("Jona"));
assertEquals(GamePhase.FLOP, game.getState().getPhase());
assertTrue(game.getCommunityCards().size() >= 3);
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("Julian"));
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size());
@@ -367,10 +494,14 @@ public class GameControllerTest {
assertEquals(3, game.getCommunityCards().size());
game.playerCall(PlayerId.of("Julian"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 4; i++) {
callCurrent(game);
}
assertEquals(4, game.getCommunityCards().size());
game.playerCall(PlayerId.of("Mathis"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size());
Set<String> boardSeen = new HashSet<>();
@@ -424,10 +555,10 @@ public class GameControllerTest {
game.startGame();
game.playerRaise(PlayerId.of("BigStack"), 1000);
game.playerCall(PlayerId.of("Caller"));
game.playerCall(PlayerId.of("MidStack"));
game.playerCall(PlayerId.of("ShortStack"));
game.playerRaise(currentPlayerId(game), 1000);
callCurrent(game);
callCurrent(game);
callCurrent(game);
for (Player p : game.getState().getPlayers()) {
assertTrue(p.getChips() >= 0, "Negative chips detected for " + p.getId());
@@ -435,13 +566,13 @@ public class GameControllerTest {
assertTrue(game.getState().getPot().getAmount() > 0);
game.playerRaise(PlayerId.of("BigStack"), 100);
game.playerRaise(currentPlayerId(game), 100);
int pot = game.getState().getPot().getAmount();
assertTrue(pot > 0, "Pot must remain valid after re-raises");
game.playerFold(PlayerId.of("Caller"));
game.playerFold(PlayerId.of("MidStack"));
foldCurrent(game);
foldCurrent(game);
long activePlayers =
game.getState().getPlayers().stream().filter(p -> p.getChips() > 0).count();
@@ -451,7 +582,7 @@ public class GameControllerTest {
assertTrue(game.getCommunityCards().size() >= 3);
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("BigStack"));
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size());
@@ -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"));
}
}