Compare commits

..

34 Commits

Author SHA1 Message Date
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 190b30672a Feat: Added CHANGE_USERNAME server command, registration and documentation 2026-04-15 20:49:29 +02:00
Lars Simon Winzer 7cba7ac1dd Merge branch 'chore/113-finalize-presentation-for-fourth-milestone' into 'main'
Finalize presentation for fourth milestone

Closes #113, #83, #82, #78, #76, #70, and #67

See merge request cs108-fs26/Gruppe-13!130
2026-04-15 16:15:06 +02:00
Lars Simon Winzer c83a360fec Docs: Remove images containing code from slides 2026-04-15 16:11:58 +02:00
Lars Simon Winzer 2631791330 Docs: Finalize presentation with exported pdf 2026-04-15 13:54:49 +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
34 changed files with 2373 additions and 274 deletions
@@ -1,8 +1,9 @@
# Protocol Document # 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. 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 # Table of Contents
- [Protocol Document](#protocol-document) - [Protocol Document](#protocol-document)
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [General structure of requests](#general-structure-of-requests) - [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) - [Error Response](#error-response-4)
- [Example Request](#example-request-2) - [Example Request](#example-request-2)
- [Example Response](#example-response-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) - [Required pre-execution checks](#required-pre-execution-checks-3)
- [Request Parameters](#request-parameters-3) - [Request Parameters](#request-parameters-3)
- [Success Response](#success-response-3) - [Success Response](#success-response-3)
- [Error Response](#error-response-5) - [Error Response](#error-response-5)
- [Example Request](#example-request-3) - [Example Request](#example-request-3)
- [Example Response](#example-response-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) - [Required pre-execution checks](#required-pre-execution-checks-4)
- [Request Parameters](#request-parameters-4) - [Request Parameters](#request-parameters-4)
- [Success Response](#success-response-4) - [Success Response](#success-response-4)
- [Error Response](#error-response-6) - [Error Response](#error-response-6)
- [Example Request](#example-request-4) - [Example Request](#example-request-4)
- [Example Response](#example-response-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) - [GET_LOBBY_LIST command](#get_lobby_list-command)
- [Required pre-execution checks](#required-pre-execution-checks) - [Required pre-execution checks](#required-pre-execution-checks)
- [Request Parameters](#request-parameters) - [Request Parameters](#request-parameters)
@@ -62,6 +70,18 @@ This document describes the protocol for client-server communication in our appl
- [Success Response](#success-response) - [Success Response](#success-response)
- [Example Request](#example-request) - [Example Request](#example-request)
- [Example Response](#example-response) - [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) - [RAISE command](#raise-command)
- [CALL command](#call-command) - [CALL command](#call-command)
- [FOLD command](#fold-command) - [FOLD command](#fold-command)
@@ -91,6 +111,7 @@ This document describes the protocol for client-server communication in our appl
<!-- Please see the comments for copy n' paste ready examples --> <!-- Please see the comments for copy n' paste ready examples -->
# General structure of requests # 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. 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.
@@ -99,41 +120,43 @@ Requests can have parameters that provide additional information for the command
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. Each collection is ended with the `END` keyword.
# Preconditions # Preconditions
The serverside pipeline to process incoming requests consists of multiple stages. 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. Each of these stages can yield an error response if the request does not meet the requirements of that stage.
## Parsing ## 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. 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 ### Error Response
| Code | Description | | Code | Description |
| :-------------- | :---------------------------------------------------------------------------------------------- | | :-------------- | :---------------------------------------------------------------------------------------------- |
| `PARSING_ERROR` | The body of the request contains syntax errors (see message field of response for more details) | | `PARSING_ERROR` | The body of the request contains syntax errors (see message field of response for more details) |
## Command dispatching ## 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. 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 ### Error Response
| Code | Description | | Code | Description |
| :---------------- | :---------------------------------------------------------------- | | :---------------- | :---------------------------------------------------------------- |
| `UNKNOWN_COMMAND` | The command is unknown to this server. No parser has been defined | | `UNKNOWN_COMMAND` | The command is unknown to this server. No parser has been defined |
## Command parsing ## 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`. 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 ### Error Response
| Code | Description | | Code | Description |
| :------------------ | :------------------------------------------------------------------------------------------------ | | :------------------ | :------------------------------------------------------------------------------------------------ |
| `MISSING_PARAMETER` | A required parameter is missing from the request (see message field of response for more details) | | `MISSING_PARAMETER` | A required parameter is missing from the request (see message field of response for more details) |
# Pre-execution checks # 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. 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 ## 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). 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 ### Error Response
| Code | Description | | Code | Description |
| :------------------- | :------------------------ | | :------------------- | :------------------------ |
| `USER_NOT_LOGGED_IN` | The user is not logged in | | `USER_NOT_LOGGED_IN` | The user is not logged in |
# Commands # 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. 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. The server processes these commands and responds accordingly.
@@ -212,41 +236,50 @@ END
--> -->
## PING command ## PING command
The `PING` command is a simple command that can be used to check if the server is responsive. The `PING` command is a simple command that can be used to check if the server is responsive.
### Required pre-execution checks ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
No parameters. No parameters.
### Success Response ### Success Response
No response fields. No response fields.
### Example Request ### Example Request
``` ```
PING PING
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
END END
``` ```
## CHECK_USERNAME command ## 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. 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 ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
| Parameter Name | Type | Optional | Description | | Parameter Name | Type | Optional | Description |
| :------------- | :------- | :------- | :------------------------------------- | | :------------- | :------- | :------- | :------------------------------------- |
| `USERNAME` | `String` | no | The username to check for availability | | `USERNAME` | `String` | no | The username to check for availability |
### Success Response ### Success Response
| Field | Type | Description | | Field | Type | Description |
| :------- | :--------------------------- | :---------------------------------------------------------------------- | | :------- | :--------------------------- | :---------------------------------------------------------------------- |
| `STATUS` | `Enum<UsernameAvailability>` | Member of enum indicating if the username is available or already taken | | `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 | | `TAKEN` | Username is already in use |
### Example Request ### Example Request
``` ```
CHECK_USERNAME USERNAME='Lars' CHECK_USERNAME USERNAME='Lars'
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
STATUS=FREE STATUS=FREE
@@ -269,33 +304,40 @@ END
``` ```
## LOGIN command ## 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. 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 ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
| Parameter Name | Type | Optional | Description | | Parameter Name | Type | Optional | Description |
| :------------- | :------- | :------- | :----------------------------------- | | :------------- | :------- | :------- | :----------------------------------- |
| `USERNAME` | `String` | no | The username to create the user with | | `USERNAME` | `String` | no | The username to create the user with |
### Success Response ### Success Response
| Field | Type | Description | | Field | Type | Description |
| :--------- | :---------------------------------------------------------------------- | :-------------------------------------------------------------------- | | :--------- | :---------------------------------------------------------------------- | :-------------------------------------------------------------------- |
| `USERNAME` | `String` | Username of the newly created user, can differ from the requested one | | `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 | | `ID` | [`UUID`](https://docs.oracle.com/javase/8/docs/api/java/util/UUID.html) | The ID of the created user |
### Error Response ### Error Response
| Code | Description | | Code | Description |
| :------------------ | :----------------------------------------------------------------------------- | | :------------------ | :----------------------------------------------------------------------------- |
| `ALREADY_LOGGED_IN` | The session is already associated with a user, logging in again is prohibited. | | `ALREADY_LOGGED_IN` | The session is already associated with a user, logging in again is prohibited. |
### Example Request ### Example Request
``` ```
LOGIN USERNAME='Lars' LOGIN USERNAME='Lars'
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
USERNAME='Lars_1234' USERNAME='Lars_1234'
@@ -303,45 +345,100 @@ LOGIN USERNAME='Lars'
END END
``` ```
## CHANGE_USERNAME command
## LOGOUT command 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.
Description of the command, what it does, and when it should be used.
### Required pre-execution checks ### Required pre-execution checks
None. None.
### Request Parameters ### 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. No parameters.
### Success Response ### Success Response
No response fields. No response fields.
### Error Response ### Error Response
| Code | Description | | Code | Description |
| :------------------- | :--------------------------------------------------------------- | | :------------------- | :--------------------------------------------------------------- |
| `NO_USER_ASSOCIATED` | The session has no user associated, logging out is not possible. | | `NO_USER_ASSOCIATED` | The session has no user associated, logging out is not possible. |
### Example Request ### Example Request
``` ```
LOGOUT LOGOUT
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
END END
``` ```
## LIST_USERS command ## LIST_USERS command
The `LIST_USERS` command is used to retrieve a list of all currently logged-in users. The `LIST_USERS` command is used to retrieve a list of all currently logged-in users.
### Required pre-execution checks ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
No parameters. No parameters.
### Success Response ### Success Response
| Field | Type | Description | | Field | Type | Description |
| :------ | :----------------- | :--------------------------------------- | | :------ | :----------------- | :--------------------------------------- |
| `USERS` | `Collection<User>` | Collection of all users currently online | | `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 | | `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 | | `ID` | [`UUID`](https://docs.oracle.com/javase/8/docs/api/java/util/UUID.html) | The ID of the created user |
### Error Response ### Error Response
None. None.
### Example Request ### Example Request
``` ```
LIST_USERS LIST_USERS
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
USERS USERS
@@ -381,8 +480,11 @@ END
``` ```
## SEND_MESSAGE command ## SEND_MESSAGE command
The `SEND_MESSAGE` command is used to transfer the chat message sent by a user to the server. The `SEND_MESSAGE` command is used to transfer the chat message sent by a user to the server.
### Required pre-execution checks ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
@@ -403,28 +505,36 @@ None.
| `WHISPER` | Message is for the whisper chat | | `WHISPER` | Message is for the whisper chat |
### Success Response ### Success Response
No response fields. No response fields.
### Error Response ### Error Response
None. None.
### Example Request ### Example Request
``` ```
SEND_MESSAGE TYPE=GLOBAL GAME=1 USER=player1 TARGET=null TIME='10:30' TEXT='Hello World' SEND_MESSAGE TYPE=GLOBAL GAME=1 USER=player1 TARGET=null TIME='10:30' TEXT='Hello World'
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
END END
``` ```
## GET_MESSAGE_COUNT command ## 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. 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 ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
No parameters. No parameters.
### Success Response ### Success Response
@@ -434,17 +544,19 @@ No parameters.
| `COUNT` | `int` | The current number of messages | | `COUNT` | `int` | The current number of messages |
### Error Response ### Error Response
| Code | Description | | Code | Description |
| :------------------- |:---------------------------------------------------------------------------------| | :------------------- |:---------------------------------------------------------------------------------|
| `NO_USER_ASSOCIATED` | The session has no user associated, there is no queue of messages for the client | | `NO_USER_ASSOCIATED` | The session has no user associated, there is no queue of messages for the client |
### Example Request ### Example Request
``` ```
GET_MESSAGE_COUNT GET_MESSAGE_COUNT
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
COUNT=10 COUNT=10
@@ -452,14 +564,19 @@ END
``` ```
## GET_NEXT_MESSAGE command ## GET_NEXT_MESSAGE command
The `GET_NEXT_MESSAGE` command is used to get the next message stored in a queue for the client. The `GET_NEXT_MESSAGE` command is used to get the next message stored in a queue for the client.
### Required pre-execution checks ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
No parameters. No parameters.
### Success Response ### Success Response
| Field | Type | Description | | Field | Type | Description |
|:---------|:----------------|:-------------------------------------------------------------------| |:---------|:----------------|:-------------------------------------------------------------------|
| `TYPE` | `Enum<ChatType` | Member of Enum, indicating with chat type is used | | `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 | | `WHISPER` | Message is for the whisper chat |
### Error Response ### Error Response
| Code | Description | | Code | Description |
| :------------------- |:---------------------------------------------------------------------------------| | :------------------- |:---------------------------------------------------------------------------------|
| `NO_USER_ASSOCIATED` | The session has no user associated, there is no queue of messages for the client | | `NO_USER_ASSOCIATED` | The session has no user associated, there is no queue of messages for the client |
### Example Request ### Example Request
``` ```
GET_NEXT_MESSAGE GET_NEXT_MESSAGE
``` ```
### Example Response ### Example Response
``` ```
+OK +OK
TYPE=GLOBAL GAME=-1 USER=player1 TARGET=null TIME=9:30 TEXT="Guten Tag" 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. The `GET_LOBBY_LIST` command requests the server to return a list of currently available lobbies with basic metadata.
### Required pre-execution checks ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
No parameters. No parameters.
### Implementation notes ### 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. 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 ### Required pre-execution checks
- [`UserLoggedInCheck`](#userloggedincheck) - [`UserLoggedInCheck`](#userloggedincheck)
### Request Parameters ### Request Parameters
| Parameter Name | Type | Optional | Description | | 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. | `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. 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 ### Required pre-execution checks
None. None.
### Request Parameters ### Request Parameters
| Parameter Name | Type | Optional | Description | | Parameter Name | Type | Optional | Description |
| :------------- | :--- | :------: | :---------- | | :------------- | :--- | :------: | :---------- |
| `ID` | `int` | no | Numeric id of the target lobby | | `ID` | `int` | no | Numeric id of the target lobby |
@@ -1151,6 +1277,7 @@ END
``` ```
### Error Response ### Error Response
| Code | Description | | Code | Description |
| :--- | :---------- | | :--- | :---------- |
| `LOBBY_NOT_FOUND` | The specified lobby id does not exist | | `LOBBY_NOT_FOUND` | The specified lobby id does not exist |
@@ -1223,3 +1350,71 @@ END
MESSAGE=Maximum number of 8 lobbies reached MESSAGE=Maximum number of 8 lobbies reached
END 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
```
Binary file not shown.
Binary file not shown.
@@ -10,6 +10,7 @@ import java.util.List;
public class GameState { public class GameState {
public List<Player> players = new ArrayList<>(); public List<Player> players = new ArrayList<>();
public List<Card> communityCards = new ArrayList<>(); public List<Card> communityCards = new ArrayList<>();
public List<String> highscoreEntries = new ArrayList<>();
public String phase; public String phase;
public int pot; public int pot;
@@ -155,6 +155,10 @@ public class GameClient {
s.winnerIndex = intVal(l); s.winnerIndex = intVal(l);
yield true; yield true;
} }
case String l when l.startsWith("HIGHSCORE=") -> {
s.highscoreEntries.add(value(l));
yield true;
}
default -> false; default -> false;
}; };
} }
@@ -81,6 +81,30 @@ public class LobbyClient {
throw new RuntimeException("No LOBBY_ID in response: " + lines); throw new RuntimeException("No LOBBY_ID in response: " + lines);
} }
/**
* Fetches the global highscores from the server.
*
* @return list of formatted highscore entries, newest entries at the end
*/
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. * Request the server to return the id of the lobby that the client is currently in.
* *
@@ -633,13 +633,14 @@ public class CasinoGameController {
*/ */
private void updateTaskbar(GameState s) { private void updateTaskbar(GameState s) {
TaskbarController controller = resolveTaskbarController(); TaskbarController controller = resolveTaskbarController();
if (controller != null) { if (controller == null || myPlayerId == null) {
if (gameService != null && myPlayerId != null) { return;
}
if (gameService != null) {
controller.setGameService(gameService, myPlayerId); controller.setGameService(gameService, myPlayerId);
} }
controller.update(s, myPlayerId); controller.update(s, myPlayerId);
} }
}
/** /**
* Update the opponent player slots based on the list of players in the game state. This method * Update the opponent player slots based on the list of players in the game state. This method
@@ -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();
}
}
@@ -1,12 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents; package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService; 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.GameState;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player; 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.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState; import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui; import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
@@ -25,7 +28,7 @@ import org.apache.logging.log4j.Logger;
*/ */
public class TaskbarController { public class TaskbarController {
private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class); private static final Logger LOGGER = LogManager.getLogger(TaskbarController.class);
@FXML private HBox taskbar; @FXML private HBox taskbar;
@FXML private TextField taskbarInput; @FXML private TextField taskbarInput;
@@ -41,9 +44,13 @@ public class TaskbarController {
private double xOffset = 0; private double xOffset = 0;
private double yOffset = 0; private double yOffset = 0;
private static final double TASKBAR_SCALE = 0.95; private static final double TASKBAR_SCALE = 0.95;
private static final int MIN_CREDITS = 5; private static final double PREFLOP_BLOCK_RATIO = 0.50;
private static final int MAX_CREDITS = 100000; private static final double FLOP_WARN_RATIO = 0.50;
private static final int CREDIT_STEP = 5; private static final double FLOP_BLOCK_RATIO = 0.80;
private static final double TURN_RIVER_WARN_RATIO = 0.80;
private static final double TURN_RIVER_BLOCK_RATIO = 1.00;
private static final int SMALL_BLIND = 100;
private static final int BIG_BLIND = 200;
private static final String STYLE_YELLOW_BUTTON = "yellow-button"; private static final String STYLE_YELLOW_BUTTON = "yellow-button";
private static final String STYLE_GRAY_BUTTON = "gray-button"; private static final String STYLE_GRAY_BUTTON = "gray-button";
private static final String STYLE_RED_BUTTON = "red-button"; private static final String STYLE_RED_BUTTON = "red-button";
@@ -51,6 +58,9 @@ public class TaskbarController {
private static final String STYLE_GRAY_INPUT = "gray-input-field"; private static final String STYLE_GRAY_INPUT = "gray-input-field";
private static final String STYLE_RED_INPUT = "red-input-field"; private static final String STYLE_RED_INPUT = "red-input-field";
private static final double SCALE_NORMAL = 1.0; private static final double SCALE_NORMAL = 1.0;
private static final int DEALER_OFFSET = 3;
private boolean inputActionAllowed;
private int lastReferenceBet = BIG_BLIND;
/** Standard constructor. Used by FXML. */ /** Standard constructor. Used by FXML. */
public TaskbarController() { public TaskbarController() {
@@ -58,7 +68,7 @@ public class TaskbarController {
} }
/** /**
* Updates the state of the basic action buttons (Bet, Call, Fold, Raise) and the input field * Updates the state of the basic action buttons (Bet, Call, Fold, Raise) and the input field.
* *
* @param isMyTurn Indicates whether it is currently the player's turn. * @param isMyTurn Indicates whether it is currently the player's turn.
* @param isOut Indicates whether the player is currently out of the game (folded or all-in). * @param isOut Indicates whether the player is currently out of the game (folded or all-in).
@@ -90,9 +100,10 @@ public class TaskbarController {
} }
/** /**
* Sets the given button to a disabled state with red styling, indicating that the player is out * Sets the given button to a disabled state with red styling, indicating that the player is
* out.
* *
* @param b The button to be styled as red and disabled * @param b The button to be styled as red and disabled.
*/ */
private void setRedButton(Button b) { private void setRedButton(Button b) {
b.setDisable(true); b.setDisable(true);
@@ -107,9 +118,9 @@ public class TaskbarController {
/** /**
* Sets the given input field to a disabled state with red styling, indicating that the player * Sets the given input field to a disabled state with red styling, indicating that the player
* is out * is out.
* *
* @param t The text field to be styled as red and disabled * @param t The text field to be styled as red and disabled.
*/ */
private void setRedInputField(TextField t) { private void setRedInputField(TextField t) {
t.setDisable(true); t.setDisable(true);
@@ -124,9 +135,9 @@ public class TaskbarController {
/** /**
* Activates the given button by enabling it and applying yellow styling, indicating that it is * Activates the given button by enabling it and applying yellow styling, indicating that it is
* the player's turn * the player's turn.
* *
* @param b The button to be activated and styled for the player's turn * @param b The button to be activated and styled for the player's turn.
*/ */
private void activateButton(Button b) { private void activateButton(Button b) {
b.setDisable(false); b.setDisable(false);
@@ -140,9 +151,9 @@ public class TaskbarController {
/** /**
* Activates the given input field by enabling it and applying yellow styling, indicating that * Activates the given input field by enabling it and applying yellow styling, indicating that
* it is the player's turn * it is the player's turn.
* *
* @param t The text field to be activated and styled for the player's turn * @param t The text field to be activated and styled for the player's turn.
*/ */
private void activateInputField(TextField t) { private void activateInputField(TextField t) {
t.setDisable(false); t.setDisable(false);
@@ -156,9 +167,9 @@ public class TaskbarController {
/** /**
* Deactivates the given button by disabling it and applying gray styling, indicating that it is * Deactivates the given button by disabling it and applying gray styling, indicating that it is
* not the player's turn * not the player's turn.
* *
* @param b The button to be deactivated and styled for non-active state * @param b The button to be deactivated and styled for non-active state.
*/ */
private void deactivateButton(Button b) { private void deactivateButton(Button b) {
b.setDisable(true); b.setDisable(true);
@@ -173,9 +184,9 @@ public class TaskbarController {
/** /**
* Deactivates the given input field by disabling it and applying gray styling, indicating that * Deactivates the given input field by disabling it and applying gray styling, indicating that
* it is not the player's turn * it is not the player's turn.
* *
* @param t The text field to be deactivated and styled for non-active state * @param t The text field to be deactivated and styled for non-active state.
*/ */
private void deactivateInputField(TextField t) { private void deactivateInputField(TextField t) {
t.setDisable(true); t.setDisable(true);
@@ -209,15 +220,25 @@ public class TaskbarController {
@FXML @FXML
public void initialize() { public void initialize() {
updateBasicButtons(false, false); updateBasicButtons(false, false);
if (taskbarInput != null) {
taskbarInput
.textProperty()
.addListener((obs, oldValue, newValue) -> refreshBetInputUi());
}
setBetButtonVisible(false);
} }
/** /**
* Updates the taskbar based on the current game state and the player's status. It checks if * Updates the taskbar based on the current game state and the player's status.
* it's the player's turn and whether they are out of the game (folded or all-in) to adjust the
* button states and displayed money accordingly.
* *
* @param state * @param state The current GameState object representing the state of the game, which includes
* @param myPlayerId * information about the players, their bets, the current phase and other relevant details
* needed to update the taskbar UI accurately based on the player's status and the game
* context.
* @param myPlayerId The PlayerId object representing the current player, used to identify the
* player's status and update the taskbar UI accordingly based on whether it is their turn
* and whether they are out of the game.
*/ */
public void update(GameState state, PlayerId myPlayerId) { public void update(GameState state, PlayerId myPlayerId) {
@@ -227,6 +248,9 @@ public class TaskbarController {
} }
this.lastState = state; this.lastState = state;
if (state.currentBet > 0) {
lastReferenceBet = state.currentBet;
}
Player me = Player me =
state.players.stream() state.players.stream()
@@ -243,9 +267,594 @@ public class TaskbarController {
boolean isMyTurn = state.activePlayer == myIndex; boolean isMyTurn = state.activePlayer == myIndex;
boolean isOut = me.getState() == PlayerState.FOLDED; boolean isOut = me.getState() == PlayerState.FOLDED;
boolean isGameFinished = isHandFinished(state);
updateBasicButtons(isMyTurn, isOut); updateBasicButtons(isMyTurn, isOut || isGameFinished);
applyActionAvailability(state, me, isMyTurn, isOut || isGameFinished);
setMoney(me.getChips()); setMoney(me.getChips());
refreshBetInputUi();
}
/**
* Applies the availability of actions (Bet, Call, Raise, Fold) based on the current game state.
*
* @param state The current game state to evaluate action availability.
* @param me The player object representing the current player, used to determine their chips
* and bet status.
* @param isMyTurn Indicates whether it is currently the player's turn, which affects whether
* actions can be taken.
* @param isOutOrFinished Indicates whether the player is out of the game (folded or all-in) or
* if the game is finished, which disables actions.
*/
private void applyActionAvailability(
GameState state, Player me, boolean isMyTurn, boolean isOutOrFinished) {
inputActionAllowed = false;
if (!isMyTurn || isOutOrFinished || state == null || me == null) {
setBetButtonVisible(false);
return;
}
int chips = Math.max(0, me.getChips());
int toCall = getToCall(state, me);
boolean forcedFoldOnly = chips <= 0 || toCall > chips;
if (forcedFoldOnly) {
setActionEnabled(betButton, false);
setActionEnabled(callButton, false);
setActionEnabled(raiseButton, false);
setActionEnabled(foldButton, true);
deactivateInputField(taskbarInput);
setBetButtonVisible(false);
return;
}
if (isFirstPreflopPlayerInputOnly(state, me)) {
setActionEnabled(betButton, true);
setActionEnabled(callButton, false);
setActionEnabled(foldButton, false);
setActionEnabled(raiseButton, false);
activateInputField(taskbarInput);
inputActionAllowed = true;
return;
}
int callTarget = effectiveCallTarget(state);
int raiseTarget = safeDouble(callTarget);
ValidationResult callValidation = validateTarget(state, me, callTarget);
ValidationResult raiseValidation = validateTarget(state, me, raiseTarget);
boolean canCall = callValidation.valid;
boolean canRaise = raiseValidation.valid;
boolean canInput = canCall || canRaise;
setActionEnabled(betButton, canInput);
setActionEnabled(callButton, canCall);
setActionEnabled(raiseButton, canRaise);
setActionEnabled(foldButton, true);
if (canInput) {
activateInputField(taskbarInput);
inputActionAllowed = true;
} else {
deactivateInputField(taskbarInput);
}
}
/**
* Safely doubles the given value while preventing integer overflow.
*
* @param value The integer value to be safely doubled.
* @return The safely doubled value, or 0 if the input is non-positive, or Integer.MAX_VALUE if
* doubling would overflow.
*/
private int safeDouble(int value) {
if (value <= 0) {
return 0;
}
if (value > Integer.MAX_VALUE / 2) {
return Integer.MAX_VALUE;
}
return value * 2;
}
/**
* Sets the visibility of the Bet button in the taskbar.
*
* @param visible A boolean indicating whether the Bet button should be visible (true) or hidden
* (false).
*/
private void setBetButtonVisible(boolean visible) {
if (betButton == null) {
return;
}
betButton.setVisible(visible);
betButton.setManaged(visible);
}
/**
* Refreshes the user interface of the bet input field and the Bet button based on the current
* game state and the validity of the input.
*/
private void refreshBetInputUi() {
if (!inputActionAllowed || taskbarInput == null || taskbarInput.isDisabled()) {
setBetButtonVisible(false);
return;
}
ValidationResult validation = validateTypedAmount(lastState, taskbarInput.getText());
boolean valid = validation.valid;
setBetButtonVisible(valid);
if (valid) {
activateButton(betButton);
} else {
deactivateButton(betButton);
}
}
/**
* Enables or disables the given button based on the provided boolean value.
*
* @param button The Button object to be enabled or disabled based on the provided boolean
* value.
* @param enabled A boolean value indicating whether the button should be enabled (true) or
* disabled (false).
*/
private void setActionEnabled(Button button, boolean enabled) {
if (enabled) {
activateButton(button);
} else {
deactivateButton(button);
}
}
/**
* Calculates the amount needed to call the current bet in the game.
*
* @param state The current GameState object representing the state of the game, which includes
* information about the current bet and the player's bet status.
* @param me The Player object representing the current player, used to determine how much they
* have already invested in the current bet.
* @return An integer representing the amount needed for the player to call the current bet.
*/
private int getToCall(GameState state, Player me) {
int current = effectiveCallTarget(state);
int alreadyInvested = Math.max(0, me.getBet());
return Math.max(0, current - alreadyInvested);
}
/**
* Determines the effective call target for the current game state. If there is an active
* current bet, it returns that as the call target.
*
* @param state The current GameState object representing the state of the game, which includes
* information about the current bet and the last reference bet.
* @return An integer representing the effective call target for the current game state.
*/
private int effectiveCallTarget(GameState state) {
if (state != null && state.currentBet > 0) {
return state.currentBet;
}
return Math.max(0, lastReferenceBet);
}
/**
* Checks if the current player is the first to act in the pre-flop phase and if the initial
* blind layout is still in place.
*
* @param state The current GameState object representing the state of the game, which includes
* information about the phase of the game, the players, the dealer position, and the active
* player.
* @param me The Player object representing the current player, used to determine their position
* in the player list and whether they are the active player.
* @return A boolean value indicating whether the current player is the first to act in the
* pre-flop phase with only the initial blind layout in place.
*/
private boolean isFirstPreflopPlayerInputOnly(GameState state, Player me) {
if (state == null || me == null || !isPreflop(state.phase) || state.players == null) {
return false;
}
int size = state.players.size();
if (size < 2) {
return false;
}
int myIndex = state.players.indexOf(me);
if (myIndex < 0) {
return false;
}
int dealer = state.dealer;
int firstIndex = (size == 2) ? dealer : (dealer + DEALER_OFFSET) % size;
if (state.activePlayer != firstIndex || myIndex != firstIndex) {
return false;
}
return isInitialPreflopBlindLayout(state);
}
/**
* Checks if the initial blind layout is still in place during the pre-flop phase.
*
* @param state The current GameState object representing the state of the game, which includes
* information about the players and their bets.
* @return A boolean value indicating whether the initial blind layout is still in place during
* the pre-flop phase.
*/
private boolean isInitialPreflopBlindLayout(GameState state) {
int sbCount = 0;
int bbCount = 0;
int zeroCount = 0;
for (Player player : state.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 == 0) {
zeroCount++;
}
}
int playerCount = state.players.size();
return playerCount >= 2 && sbCount == 1 && bbCount == 1 && zeroCount == playerCount - 2;
}
/**
* Checks if the given phase string corresponds to the pre-flop phase of the game.
*
* @param phase The string representing the current phase of the game, which is expected to be
* compared against "PREFLOP" to determine if it is the pre-flop phase.
* @return A boolean value indicating whether the given phase string corresponds to the pre-flop
* phase.
*/
private boolean isPreflop(String phase) {
return "PREFLOP".equalsIgnoreCase(phase);
}
/**
* Checks if the given phase string corresponds to the flop phase of the game.
*
* @param phase The string representing the current phase of the game, which is expected to be
* compared against "FLOP" to determine if it is the flop phase.
* @return A boolean value indicating whether the given phase string corresponds to the flop
* phase.
*/
private boolean isFlop(String phase) {
return "FLOP".equalsIgnoreCase(phase);
}
/**
* Checks if the given phase string corresponds to either the turn or river phase of the game.
*
* @param phase The string representing the current phase of the game, which is expected to be
* compared against "TURN" and "RIVER" to determine if it is either the turn or river phase.
* @return A boolean value indicating whether the given phase string corresponds to either the
* turn or river phase.
*/
private boolean isTurnOrRiver(String phase) {
return "TURN".equalsIgnoreCase(phase) || "RIVER".equalsIgnoreCase(phase);
}
/**
* Checks if the hand is finished based on the current game state.
*
* @param state The current GameState object representing the state of the game, which includes
* information
* @return A boolean value indicating whether the hand is finished, which can be determined by
* checking if the phase is "FINISHED" or "SHOWDOWN",
*/
private boolean isHandFinished(GameState state) {
if (state == null) {
return false;
}
if ("FINISHED".equalsIgnoreCase(state.phase) || "SHOWDOWN".equalsIgnoreCase(state.phase)) {
return true;
}
return state.players != null
&& state.winnerIndex >= 0
&& state.winnerIndex < state.players.size();
}
/**
* Evaluates the risk level of a proposed bet based on the current game state, the amount of the
* bet and the player's available chips.
*/
private enum BetRisk {
ALLOWED,
WARNING,
BLOCKED
}
/** Enum representing the types of actions that can be submitted from the taskbar. */
private enum ActionType {
INPUT,
CALL_BUTTON,
RAISE_BUTTON
}
/** Class representing the result of validating a proposed bet or action. */
private static final class ValidationResult {
private final boolean valid;
private final boolean warning;
private final String message;
private ValidationResult(boolean valid, boolean warning, String message) {
this.valid = valid;
this.warning = warning;
this.message = message;
}
private static ValidationResult ok() {
return new ValidationResult(true, false, null);
}
private static ValidationResult warning(String message) {
return new ValidationResult(true, true, message);
}
private static ValidationResult blocked(String message) {
return new ValidationResult(false, false, message);
}
}
/**
* Submits a player action based on the specified ActionType.
*
* @param actionType The type of action being submitted, which can be an input-based action
* (where the player types an amount) or a specific button action for calling or raising.
*/
private void submitAction(ActionType actionType) {
if (gameService == null) {
LOGGER.error("GameService not initialized");
return;
}
GameState state = ensureLatestStateForAction(actionType.name().toLowerCase());
if (state == null) {
return;
}
if (isHandFinished(state)) {
LOGGER.info("Action {} ignored: hand is already finished", actionType);
update(state, myPlayerId);
return;
}
Player me = findCurrentPlayer(state);
if (me == null) {
LOGGER.error("Action {} blocked: player missing in state", actionType);
return;
}
int callTarget = effectiveCallTarget(state);
int targetBet;
if (actionType == ActionType.CALL_BUTTON) {
targetBet = callTarget;
} else if (actionType == ActionType.RAISE_BUTTON) {
targetBet = safeDouble(callTarget);
} else {
Integer fromInput = parseInputTarget(taskbarInput.getText());
if (fromInput == null) {
return;
}
targetBet = fromInput;
}
ValidationResult validation = validateTarget(state, me, targetBet);
if (!validation.valid) {
refreshBetInputUi();
return;
}
if (validation.warning) {
showWarningDialog("WARNING", validation.message);
}
if (state.currentBet <= 0) {
gameService.bet(targetBet);
LOGGER.info("Player BET (target={})", targetBet);
} else if (targetBet == callTarget) {
gameService.call();
LOGGER.info("Player CALL (target={})", targetBet);
} else {
int raiseBy = Math.max(0, targetBet - state.currentBet);
gameService.raise(raiseBy);
LOGGER.info("Player RAISE by {} (target={})", raiseBy, targetBet);
}
if (targetBet > 0) {
lastReferenceBet = targetBet;
}
taskbarInput.clear();
refreshGame();
}
/**
* Ensures that the latest game state is available for processing a player action. If the last
* known state is null, it attempts to fetch the current state from the GameService.
*
* @param text The text associated with the action being processed, used for logging purposes to
* indicate which action is being attempted when the state is not available.
* @return The latest GameState object if available, or null if the state cannot be retrieved,
* indicating that the action cannot be processed.
*/
private Integer parseInputTarget(String text) {
if (text == null || text.trim().isEmpty()) {
return null;
}
try {
return Integer.parseInt(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
/**
* Finds the current player in the given game state based on the player's ID.
*
* @param state The current GameState object representing the state of the game, which includes
* a list of players.
* @param text The text associated with the action being processed, used for logging purposes to
* indicate which action is being attempted when the player cannot be found in the state.
* @return The Player object representing the current player if found in the game state, or null
* if no matching player is found, indicating that the player cannot be identified in the
* current game state.
*/
private ValidationResult validateTypedAmount(GameState state, String text) {
Integer target = parseInputTarget(text);
if (target == null || state == null) {
return ValidationResult.blocked("Invalid input");
}
Player me = findCurrentPlayer(state);
if (me == null) {
return ValidationResult.blocked("Player not found");
}
return validateTarget(state, me, target);
}
/**
* Validates a proposed target bet against the current game state and the player's status. It
* checks if the target bet is either a valid call or a valid raise (specifically, exactly
* double the call target).
*
* @param state The current GameState object representing the state of the game, which includes
* information about the current bet, the phase of the game, and the players.
* @param me The Player object representing the current player, used to determine their current
* bet, available chips, and to evaluate whether the proposed target bet is valid for this
* player based on their status in the game.
* @param targetBet The integer value representing the proposed target bet that the player
* intends to make.
* @return A ValidationResult object indicating whether the proposed target bet is valid, if it
* triggers a warning, or if it is blocked due to being invalid or too risky.
*/
private ValidationResult validateTarget(GameState state, Player me, int targetBet) {
if (state == null || me == null) {
return ValidationResult.blocked("Invalid game state");
}
int callTarget = effectiveCallTarget(state);
int raiseTarget = safeDouble(callTarget);
if (targetBet != callTarget && targetBet != raiseTarget) {
return ValidationResult.blocked("Only calls or exactly two raises allowed");
}
int alreadyInvested = Math.max(0, me.getBet());
int required = Math.max(0, targetBet - alreadyInvested);
int chips = Math.max(0, me.getChips());
if (targetBet == callTarget && required == 0) {
return ValidationResult.ok();
}
if (required <= 0) {
return ValidationResult.blocked("Invalid bet");
}
if (required > chips) {
return ValidationResult.blocked("Not enough chips for the next bet");
}
BetRisk risk = evaluateBetRisk(state, required, chips);
if (risk == BetRisk.BLOCKED) {
return ValidationResult.blocked("Assignment for this phase is blocked");
}
if (risk == BetRisk.WARNING) {
return ValidationResult.warning(warningTextForPhase(state));
}
return ValidationResult.ok();
}
/**
* Generates a warning message based on the current phase of the game.
*
* @param state The current GameState object representing the state of the game, which includes
* information about the phase of the game.
* @return A string containing the warning message appropriate for the current phase of the game
* when a player's bet triggers a warning due to being at least 50% of their stack.
*/
private String warningTextForPhase(GameState state) {
String phase = state != null ? state.phase : null;
if (isFlop(phase)) {
return "WARNING: On the flop, you're betting at least 50% of your stack.";
}
if (isTurnOrRiver(phase)) {
return "WARNING: On the flop, you're betting at least 50% of your stack.";
}
return "WARNING: High stakes.";
}
/**
* Evaluates the risk level of a proposed bet based on the current game state, the amount of the
* bet, and the player's available chips.
*
* @param state The current GameState object representing the state of the game, which includes
* information about the phase of the game.
* @param amount The integer value representing the amount of the proposed bet that the player
* intends to make.
* @param chips The integer value representing the player's available chips.
* @return A BetRisk enum value indicating the risk level of the proposed bet.
*/
private BetRisk evaluateBetRisk(GameState state, int amount, int chips) {
if (chips <= 0 || amount <= 0) {
return BetRisk.BLOCKED;
}
double ratio = (double) amount / chips;
String phase = state != null ? state.phase : null;
if (isPreflop(phase)) {
if (ratio > PREFLOP_BLOCK_RATIO) {
return BetRisk.BLOCKED;
}
return BetRisk.ALLOWED;
}
if (isFlop(phase)) {
if (ratio > FLOP_BLOCK_RATIO) {
return BetRisk.BLOCKED;
}
if (ratio >= FLOP_WARN_RATIO) {
return BetRisk.WARNING;
}
return BetRisk.ALLOWED;
}
if (isTurnOrRiver(phase)) {
if (ratio >= TURN_RIVER_BLOCK_RATIO) {
return BetRisk.BLOCKED;
}
if (ratio > TURN_RIVER_WARN_RATIO) {
return BetRisk.WARNING;
}
return BetRisk.ALLOWED;
}
return BetRisk.ALLOWED;
}
/**
* Displays a warning dialog with the specified title and content.
*
* @param title The string representing the title of the warning dialog, which is displayed in
* the title bar of the dialog window.
* @param content The string representing the content of the warning message, which is displayed
* in the body of the dialog.
*/
private void showWarningDialog(String title, String content) {
Alert alert = new Alert(Alert.AlertType.WARNING);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(content);
alert.showAndWait();
} }
/** /**
@@ -297,6 +906,12 @@ public class TaskbarController {
@FXML @FXML
private void onInputSubmitted(KeyEvent event) { private void onInputSubmitted(KeyEvent event) {
if (event.getCode() == KeyCode.ENTER) { if (event.getCode() == KeyCode.ENTER) {
ValidationResult validation = validateTypedAmount(lastState, taskbarInput.getText());
if (!validation.valid) {
event.consume();
setBetButtonVisible(false);
return;
}
processBet(); processBet();
} }
} }
@@ -313,16 +928,7 @@ public class TaskbarController {
/** Called when the Call button is clicked. */ /** Called when the Call button is clicked. */
@FXML @FXML
private void onInputPlayerCall() { private void onInputPlayerCall() {
submitPresetInputAndProcess("call");
if (gameService == null) {
LOGGER.error("GameService not initialized");
return;
}
gameService.call();
LOGGER.info("Player CALL");
refreshGame();
} }
/** Called when the Fold button is clicked. */ /** Called when the Fold button is clicked. */
@@ -334,6 +940,13 @@ public class TaskbarController {
return; return;
} }
GameState state = ensureLatestStateForAction("fold");
if (isHandFinished(state)) {
LOGGER.info("Fold ignored: hand is already finished");
update(state, myPlayerId);
return;
}
gameService.fold(); gameService.fold();
LOGGER.info("Player FOLD"); LOGGER.info("Player FOLD");
@@ -343,36 +956,25 @@ public class TaskbarController {
/** Called when the Raise button is clicked. */ /** Called when the Raise button is clicked. */
@FXML @FXML
private void onInputPlayerRaise() { private void onInputPlayerRaise() {
submitPresetInputAndProcess("raise");
}
if (gameService == null) { /**
LOGGER.error("GameService not initialized"); * Submits a preset input for either calling or raising based on the specified mode.
*
* @param mode A string indicating the mode of the action, which can be "call" for calling the
* current bet or "raise" for raising to double the current bet.
*/
private void submitPresetInputAndProcess(String mode) {
GameState state = ensureLatestStateForAction(mode);
if (state == null || taskbarInput == null) {
return; return;
} }
GameState state = lastState; int callTarget = effectiveCallTarget(state);
if (state == null) { int target = "raise".equals(mode) ? safeDouble(callTarget) : callTarget;
try { taskbarInput.setText(String.valueOf(target));
state = gameService.refresh(); processBet();
lastState = state;
} catch (Exception e) {
LOGGER.error("Failed to refresh game state for raise: {}", e.getMessage());
return;
}
}
if (state == null || state.currentBet <= 0) {
LOGGER.warn(
"Raise not possible: current bet is {}",
state != null ? state.currentBet : null);
return;
}
int raiseAmount = state.currentBet;
gameService.raise(raiseAmount);
LOGGER.info("Player RAISE by {} (target: double table bet)", raiseAmount);
taskbarInput.clear();
refreshGame();
} }
/** /**
@@ -433,35 +1035,40 @@ public class TaskbarController {
* displayed on the console. * displayed on the console.
*/ */
private void processBet() { private void processBet() {
submitAction(ActionType.INPUT);
if (gameService == null) {
LOGGER.error("Error: GameService not initialized");
return;
} }
String input = taskbarInput.getText(); private Player findCurrentPlayer(GameState state) {
if (state == null || state.players == null || myPlayerId == null) {
return null;
}
return state.players.stream()
.filter(player -> player != null && myPlayerId.equals(player.getId()))
.findFirst()
.orElse(null);
}
/**
* Ensures that the latest game state is available for processing a player action.
*
* @param actionName The name of the action being processed, used for logging purposes to
* indicate which action is being attempted when the state is not available.
* @return The latest GameState object if available, or null if the state cannot be retrieved,
* indicating that the action cannot be processed due to the lack of a valid game state.
*/
private GameState ensureLatestStateForAction(String actionName) {
try { try {
GameState refreshed = gameService.refresh();
int credits = Integer.parseInt(input.trim()); if (refreshed != null) {
lastState = refreshed;
if (credits >= MIN_CREDITS && credits <= MAX_CREDITS && credits % CREDIT_STEP == 0) { return refreshed;
}
gameService.bet(credits); } catch (Exception e) {
LOGGER.error("Failed to refresh game state for {}: {}", actionName, e.getMessage());
LOGGER.info("Bet placed: {}", credits);
taskbarInput.clear();
refreshGame();
} else {
LOGGER.error("Error: Bet must be between 5 and 100000 and multiple of 5");
} }
} catch (NumberFormatException e) { return lastState;
LOGGER.error("Error: Invalid number entered");
}
} }
/** /**
@@ -474,4 +1081,47 @@ public class TaskbarController {
private void onBrowserButtonClick() { private void onBrowserButtonClick() {
CasinoBrowserController.open("wikipedia.org"); CasinoBrowserController.open("wikipedia.org");
} }
/** Opens the highscore popup window from the taskbar. */
@FXML
private void onHighscoreButtonClick() {
try {
var shared = ClientApp.getSharedClientService();
if (shared == null || shared.isOffline()) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Info");
alert.setHeaderText(null);
alert.setContentText("No active server connection for highscores.");
alert.showAndWait();
return;
}
javafx.fxml.FXMLLoader loader =
new javafx.fxml.FXMLLoader(
getClass()
.getResource(
"/ui-structure/gameuicomponents/HighscoreView.fxml"));
javafx.scene.Parent root = loader.load();
HighscoreViewController controller = loader.getController();
controller.setLobbyClient(new LobbyClient(shared));
controller.refreshHighscores();
javafx.stage.Stage stage = new javafx.stage.Stage();
stage.initStyle(javafx.stage.StageStyle.TRANSPARENT);
stage.setTitle("Casono Highscores");
javafx.scene.image.Image icon =
new javafx.scene.image.Image(
getClass().getResource("/images/logoinverted.png").toExternalForm());
stage.getIcons().add(icon);
javafx.scene.Scene scene = new javafx.scene.Scene(root);
scene.setFill(javafx.scene.paint.Color.TRANSPARENT);
stage.setScene(scene);
stage.show();
stage.setAlwaysOnTop(true);
stage.toFront();
} catch (Exception e) {
LOGGER.error("Could not open highscore window from taskbar: {}", e.getMessage());
}
}
} }
@@ -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.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService; 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.network.LobbyClient;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.HighscoreViewController;
import java.io.IOException; import java.io.IOException;
import java.net.URL; import java.net.URL;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType; import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button; import javafx.scene.control.Button;
@@ -20,6 +23,8 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane; import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.shape.Rectangle; import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -287,4 +292,36 @@ public class CasinomainuiController {
LOGGER.error("Failed to create or add lobby: {}", e.getMessage()); 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,17 +1,56 @@
package ch.unibas.dmi.dbis.cs108.casono.server; 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.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.CheckUsernameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameRequest; 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.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.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_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.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.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.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.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.ListUsersParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersRequest; 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.LoginHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginParser; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginRequest; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginRequest;
@@ -28,6 +67,7 @@ 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.UserCleanupJob;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; 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.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.CommandHandlerExecutor;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter; 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.command.parsing.CommandParserDispatcher;
@@ -168,6 +208,15 @@ public class ServerApp {
commandRouter.register( commandRouter.register(
LoginRequest.class, new LoginHandler(responseDispatcher, userRegistry)); 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()); parserDispatcher.register("LOGOUT", new LogoutParser());
commandRouter.register( commandRouter.register(
LogoutRequest.class, new LogoutHandler(responseDispatcher, userRegistry)); LogoutRequest.class, new LogoutHandler(responseDispatcher, userRegistry));
@@ -192,152 +241,96 @@ public class ServerApp {
ListUsersRequest.class, new ListUsersHandler(responseDispatcher, userRegistry)); ListUsersRequest.class, new ListUsersHandler(responseDispatcher, userRegistry));
// GET_LOBBY_LIST registration // GET_LOBBY_LIST registration
parserDispatcher.register( parserDispatcher.register("GET_LOBBY_LIST", new GetLobbyListParser());
"GET_LOBBY_LIST",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list GetLobbyListRequest.class,
.GetLobbyListRequest.class, (CommandHandler<GetLobbyListRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new GetLobbyListHandler(responseDispatcher, context.lobbyManager()));
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()));
// GET_GAME_STATE registration // GET_GAME_STATE registration
parserDispatcher.register( parserDispatcher.register("GET_GAME_STATE", new GetGameStateParser());
"GET_GAME_STATE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state GetGameStateRequest.class,
.GetGameStateRequest.class, (CommandHandler<GetGameStateRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new GetGameStateHandler(
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(
responseDispatcher, context.lobbyManager(), userRegistry)); responseDispatcher, context.lobbyManager(), userRegistry));
// BET registration parserDispatcher.register("GET_HIGHSCORES", new GetHighscoresParser());
parserDispatcher.register(
"BET",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetRequest.class, GetHighscoresRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< (CommandHandler<GetHighscoresRequest>)
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet new GetHighscoresHandler(responseDispatcher));
.PlayerBetRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet parserDispatcher.register("CLEAR_HIGHSCORES", new ClearHighscoresParser());
.PlayerBetHandler( 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())); responseDispatcher, userRegistry, context.lobbyManager()));
// RAISE registration // RAISE registration
parserDispatcher.register( parserDispatcher.register("RAISE", new PlayerRaiseParser());
"RAISE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest PlayerRaiseRequest.class,
.class, (CommandHandler<PlayerRaiseRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new PlayerRaiseHandler(
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(
responseDispatcher, userRegistry, context.lobbyManager())); responseDispatcher, userRegistry, context.lobbyManager()));
// CALL registration // CALL registration
parserDispatcher.register( parserDispatcher.register("CALL", new PlayerCallParser());
"CALL",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallRequest PlayerCallRequest.class,
.class, (CommandHandler<PlayerCallRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new PlayerCallHandler(
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(
responseDispatcher, userRegistry, context.lobbyManager())); responseDispatcher, userRegistry, context.lobbyManager()));
// FOLD registration // FOLD registration
parserDispatcher.register( parserDispatcher.register("FOLD", new PlayerFoldParser());
"FOLD",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest PlayerFoldRequest.class,
.class, (CommandHandler<PlayerFoldRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new PlayerFoldHandler(
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(
responseDispatcher, userRegistry, context.lobbyManager())); responseDispatcher, userRegistry, context.lobbyManager()));
// GET_LOBBY_STATUS registration // GET_LOBBY_STATUS registration
parserDispatcher.register( parserDispatcher.register("GET_LOBBY_STATUS", new GetLobbyStatusParser());
"GET_LOBBY_STATUS",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status GetLobbyStatusRequest.class,
.GetLobbyStatusRequest.class, (CommandHandler<GetLobbyStatusRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new GetLobbyStatusHandler(
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(
responseDispatcher, context.lobbyManager(), userRegistry)); responseDispatcher, context.lobbyManager(), userRegistry));
// CREATE_LOBBY registration // CREATE_LOBBY registration
parserDispatcher.register( parserDispatcher.register("CREATE_LOBBY", new CreateLobbyParser());
"CREATE_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby CreateLobbyRequest.class,
.CreateLobbyRequest.class, (CommandHandler<CreateLobbyRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new CreateLobbyHandler(
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(
responseDispatcher, responseDispatcher,
context.lobbyManager(), context.lobbyManager(),
context.sessionManager())); context.sessionManager()));
// JOIN_LOBBY registration // JOIN_LOBBY registration
parserDispatcher.register( parserDispatcher.register("JOIN_LOBBY", new JoinLobbyParser());
"JOIN_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby JoinLobbyRequest.class,
.JoinLobbyRequest.class, (CommandHandler<JoinLobbyRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new JoinLobbyHandler(
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(
responseDispatcher, context.lobbyManager(), userRegistry)); responseDispatcher, context.lobbyManager(), userRegistry));
// START_GAME registration // START_GAME registration
parserDispatcher.register( parserDispatcher.register("START_GAME", new StartGameParser());
"START_GAME",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameParser());
commandRouter.register( commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game StartGameRequest.class,
.StartGameRequest.class, (CommandHandler<StartGameRequest>)
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler< new StartGameHandler(
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(
responseDispatcher, context.lobbyManager(), userRegistry)); responseDispatcher, context.lobbyManager(), userRegistry));
} }
} }
@@ -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());
}
}
@@ -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.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.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; 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.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.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.builder.ResponseBody;
@@ -50,7 +51,11 @@ public class GetGameStateResponse extends SuccessResponse {
builder.param("CURRENT_BET", computeGlobalCurrentBet(state)); builder.param("CURRENT_BET", computeGlobalCurrentBet(state));
builder.param("DEALER", state.getDealerIndex()); builder.param("DEALER", state.getDealerIndex());
builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex()); 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); appendCommunityCards(builder, state);
@@ -59,6 +64,36 @@ public class GetGameStateResponse extends SuccessResponse {
return builder.build(); return builder.build();
} }
private static void appendWinnerToHighscoresIfFinished(GameState state, int winnerIndex) {
if (winnerIndex < 0 || state.getPhase() != GamePhase.FINISHED) {
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) { private static int computeWinnerIndex(GameState state, GameController game) {
GamePhase phase = state.getPhase(); GamePhase phase = state.getPhase();
if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) { if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) {
@@ -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());
}
}
@@ -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.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.HandEvaluator;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandRank; 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.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.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
@@ -183,9 +184,22 @@ public class GameController {
* *
* @param playerId The ID of the player who is folding. * @param playerId The ID of the player who is folding.
*/ */
public void playerFold(PlayerId playerId) { public PlayerId playerFold(PlayerId playerId) {
// engine.processAction(new FoldAction(PlayerId.of(playerId)));
engine.processAction(new FoldAction(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; 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.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.rules.RuleEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; 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) { 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 // 1.Check the rules
ruleEngine.validate(state, action); ruleEngine.validate(state, action);
// 2. Perform action // 2. Perform action
action.execute(state); action.execute(state);
if (action.getType() != ActionType.BLIND) {
state.markActedThisRound(action.getPlayerId());
}
// 3. Next turn // 3. Next turn
turnManager.nextPlayer(state); 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.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.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; 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.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import java.util.ArrayList; import java.util.ArrayList;
@@ -27,6 +26,13 @@ public class RoundManager {
public static final int SMALL_BLIND = 100; public static final int SMALL_BLIND = 100;
public static final int BIG_BLIND = 200; 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) { public void startNewHand(GameState state) {
// Use GameState's canonical reset // Use GameState's canonical reset
state.startNewHand(); state.startNewHand();
@@ -44,12 +50,19 @@ public class RoundManager {
state.setCurrentPlayerToPreflopFirstToAct(); 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) { public void progressIfNeeded(GameState state) {
if (state.getPhase() == null) { if (state.getPhase() == null) {
state.setPhase(GamePhase.PREFLOP); state.setPhase(GamePhase.PREFLOP);
} }
if (state.countNonFoldedPlayers() == 1) { if (state.countNonFoldedPlayers() == 1) {
state.setHandActive(false);
state.setPhase(GamePhase.FINISHED); state.setPhase(GamePhase.FINISHED);
return; 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) { 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()) { for (Player p : state.getPlayers()) {
if (p == null) { if (p == null || p.isFolded() || p.isAllIn()) {
continue; continue;
} }
// be tolerant if codebase mixes status + boolean flags if (!state.hasActedThisRound(p.getId())) {
if (p.getStatus() == PlayerStatus.FOLDED || p.isFolded()) {
continue;
}
int bet = state.getCurrentBet(p.getId());
if (bet < target && !p.isAllIn()) {
return false; return false;
} }
} }
return true; 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) { private void advancePhase(GameState state) {
switch (state.getPhase()) { switch (state.getPhase()) {
case PREFLOP -> dealFlop(state); 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) { private void ensureDeck(GameState state) {
Deck deck = state.getDeck(); Deck deck = state.getDeck();
if (deck == null) { 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) { private void dealHoleCards(GameState state) {
Deck deck = state.getDeck(); Deck deck = state.getDeck();
@@ -165,6 +215,14 @@ public class RoundManager {
state.getTableState().setCurrentBet(BIG_BLIND); 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) { private void dealFlop(GameState state) {
ensureDeck(state); ensureDeck(state);
Deck deck = state.getDeck(); Deck deck = state.getDeck();
@@ -180,6 +238,14 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct(); 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) { private void dealTurn(GameState state) {
ensureDeck(state); ensureDeck(state);
Deck deck = state.getDeck(); Deck deck = state.getDeck();
@@ -193,6 +259,14 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct(); 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) { private void dealRiver(GameState state) {
ensureDeck(state); ensureDeck(state);
Deck deck = state.getDeck(); Deck deck = state.getDeck();
@@ -206,6 +280,12 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct(); 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) { private void showdown(GameState state) {
state.setPhase(GamePhase.SHOWDOWN); state.setPhase(GamePhase.SHOWDOWN);
@@ -7,8 +7,10 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* The GameState class encapsulates the entire state of a poker game at any given moment. * The GameState class encapsulates the entire state of a poker game at any given moment.
@@ -38,6 +40,7 @@ public class GameState {
private final Map<PlayerId, Integer> currentBets = new HashMap<>(); private final Map<PlayerId, Integer> currentBets = new HashMap<>();
private final Map<PlayerId, Integer> playerBetCommitments = 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 Map<PlayerId, List<Card>> holeCards = new HashMap<>();
private final List<Card> communityCards = new ArrayList<>(); private final List<Card> communityCards = new ArrayList<>();
@@ -46,7 +49,11 @@ public class GameState {
private static final int DEALER_OFFSET = 3; 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() { public Collection<Player> getPlayers() {
List<Player> ordered = new ArrayList<>(playerOrder.size()); List<Player> ordered = new ArrayList<>(playerOrder.size());
for (PlayerId id : playerOrder) { for (PlayerId id : playerOrder) {
@@ -58,57 +65,123 @@ public class GameState {
return ordered; 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() { public Player getCurrentPlayer() {
PlayerId id = playerOrder.get(currentPlayerIndex); PlayerId id = playerOrder.get(currentPlayerIndex);
return players.get(id); 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() { public int getCurrentPlayerIndex() {
return currentPlayerIndex; 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() { public boolean isHandActive() {
return handActive; return handActive;
} }
/**
* Returns the current phase of the game (e.g., PREFLOP, FLOP, TURN, RIVER).
*
* @return the current game phase.
*/
public GamePhase getPhase() { public GamePhase getPhase() {
return phase; 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() { public TableState getTableState() {
return tableState; 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() { public Pot getPot() {
return pot; return pot;
} }
/**
* Returns the current deck of cards being used in the game.
*
* @return the current deck of cards.
*/
public Deck getDeck() { public Deck getDeck() {
return deck; 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() { public List<Card> getCommunityCards() {
return communityCards; 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) { public List<Card> getHoleCards(PlayerId playerId) {
return holeCards.computeIfAbsent(playerId, k -> new ArrayList<>()); 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() { public int getDealerIndex() {
return dealerIndex; 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() { public Map<PlayerId, List<Card>> getPlayerCards() {
return holeCards; 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() { public int getPlayerCount() {
return players.size(); return players.size();
} }
// Setters / Mutators
public void setCurrentPlayerIndex(int index) { public void setCurrentPlayerIndex(int index) {
if (playerOrder.isEmpty()) { if (playerOrder.isEmpty()) {
this.currentPlayerIndex = 0; this.currentPlayerIndex = 0;
@@ -117,6 +190,10 @@ public class GameState {
this.currentPlayerIndex = index; 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() { public void setCurrentPlayerToPreflopFirstToAct() {
int size = playerOrder.size(); int size = playerOrder.size();
if (size == 0) { if (size == 0) {
@@ -127,11 +204,16 @@ public class GameState {
// Heads-up: dealer (small blind) acts first preflop. // Heads-up: dealer (small blind) acts first preflop.
int first = (size == 2) ? dealerIndex : (dealerIndex + DEALER_OFFSET) % size; int first = (size == 2) ? dealerIndex : (dealerIndex + DEALER_OFFSET) % size;
currentPlayerIndex = first; currentPlayerIndex = first;
resetRoundActions();
if (!canPlayerAct(currentPlayerIndex)) { if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer(); 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() { public void setCurrentPlayerToPostflopFirstToAct() {
int size = playerOrder.size(); int size = playerOrder.size();
if (size == 0) { if (size == 0) {
@@ -141,27 +223,86 @@ public class GameState {
int first = (dealerIndex + 1) % size; int first = (dealerIndex + 1) % size;
currentPlayerIndex = first; currentPlayerIndex = first;
resetRoundActions();
if (!canPlayerAct(currentPlayerIndex)) { if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer(); 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) { public void setHandActive(boolean handActive) {
this.handActive = 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) { public void setPhase(GamePhase phase) {
this.phase = 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) { public void setDealerIndex(int dealerIndex) {
this.dealerIndex = 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) { public void setDeck(Deck deck) {
this.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) { public void addPlayer(PlayerId id, int chips) {
Player player = new Player(id, chips); Player player = new Player(id, chips);
@@ -213,24 +354,48 @@ public class GameState {
return true; 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( private <T> void moveMapEntry(
Map<PlayerId, T> map, PlayerId oldId, PlayerId newId, T fallback) { Map<PlayerId, T> map, PlayerId oldId, PlayerId newId, T fallback) {
T value = map.remove(oldId); T value = map.remove(oldId);
map.put(newId, value != null ? value : fallback); map.put(newId, value != null ? value : fallback);
} }
// Betting // 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) { public int getCurrentBet(PlayerId playerId) {
return currentBets.getOrDefault(playerId, 0); 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) { public void setCurrentBet(PlayerId playerId, int amount) {
currentBets.put(playerId, amount); currentBets.put(playerId, amount);
} }
/** /** Reset bets to 0 for ALL players (do not clear the map). */
* Reset bets to 0 for ALL players (do not clear the map). Also resets table current bet to 0.
*/
public void resetBets() { public void resetBets() {
for (PlayerId id : playerOrder) { for (PlayerId id : playerOrder) {
currentBets.put(id, 0); currentBets.put(id, 0);
@@ -238,10 +403,20 @@ public class GameState {
tableState.setCurrentBet(0); tableState.setCurrentBet(0);
} }
/**
* Adds the specified amount to the pot.
*
* @param amount the amount to add to the pot.
*/
public void addToPot(int amount) { public void addToPot(int amount) {
pot.add(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() { public boolean isAllowOutOfTurn() {
return allowOutOfTurn; return allowOutOfTurn;
} }
@@ -250,7 +425,13 @@ public class GameState {
this.allowOutOfTurn = allowOutOfTurn; 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) { public Player getPlayer(PlayerId id) {
Player player = players.get(id); Player player = players.get(id);
if (player == null) { if (player == null) {
@@ -260,7 +441,13 @@ public class GameState {
return player; 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) { public int getCurrentBetCommitment(PlayerId playerId) {
return playerBetCommitments.getOrDefault(playerId, 0); return playerBetCommitments.getOrDefault(playerId, 0);
} }
@@ -269,7 +456,7 @@ public class GameState {
playerBetCommitments.put(playerId, amount); playerBetCommitments.put(playerId, amount);
} }
// Cards // CARDS
/** /**
* Gives (overwrites) the two hole cards for the given player. Ensures stable list identity * Gives (overwrites) the two hole cards for the given player. Ensures stable list identity
@@ -282,15 +469,21 @@ public class GameState {
cards.add(c2); 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) { public void addCommunityCard(Card card) {
communityCards.add(card); communityCards.add(card);
} }
/** Resets the community cards by clearing the list of community cards on the table. */
public void resetCommunityCards() { public void resetCommunityCards() {
communityCards.clear(); communityCards.clear();
} }
// Turn / dealer management /** Advances the current player index to the next player who can act. */
public void nextPlayer() { public void nextPlayer() {
if (playerOrder.isEmpty()) { if (playerOrder.isEmpty()) {
currentPlayerIndex = 0; currentPlayerIndex = 0;
@@ -308,6 +501,14 @@ public class GameState {
} while (currentPlayerIndex != start); } 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) { private boolean canPlayerAct(int index) {
if (index < 0 || index >= playerOrder.size()) { if (index < 0 || index >= playerOrder.size()) {
return false; return false;
@@ -322,12 +523,19 @@ public class GameState {
dealerIndex = (dealerIndex + 1) % playerOrder.size(); 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() { public Player getDealer() {
PlayerId id = playerOrder.get(dealerIndex); PlayerId id = playerOrder.get(dealerIndex);
return players.get(id); 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. * Starts a new hand by resetting the game state for the next round of poker.
@@ -371,6 +579,13 @@ public class GameState {
getPlayer(playerId).setFolded(true); 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) { public boolean isFolded(PlayerId playerId) {
return getPlayer(playerId).isFolded(); 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", " ");
}
}
@@ -81,15 +81,17 @@
</GridPane.margin> </GridPane.margin>
</Button> </Button>
<!-- Username input and login/change button --> <!-- Username input + change name, with highscores directly below -->
<HBox alignment="CENTER_LEFT" spacing="10" <VBox alignment="TOP_LEFT" spacing="10"
GridPane.rowIndex="0" GridPane.rowIndex="0"
GridPane.columnIndex="0" GridPane.columnIndex="0"
GridPane.halignment="LEFT" GridPane.halignment="LEFT"
GridPane.valignment="TOP"> GridPane.valignment="TOP">
<GridPane.margin> <GridPane.margin>
<Insets top="100" left="20" /> <Insets top="140" left="20" />
</GridPane.margin> </GridPane.margin>
<HBox alignment="CENTER_LEFT" spacing="10">
<TextField fx:id="usernameField" <TextField fx:id="usernameField"
promptText="Username" promptText="Username"
styleClass="gray-input-field" styleClass="gray-input-field"
@@ -100,6 +102,12 @@
styleClass="button-create-lobby" /> styleClass="button-create-lobby" />
</HBox> </HBox>
<Button text="HIGHSCORES"
styleClass="button-create-lobby"
fx:id="highscoreButton"
onAction="#handleOpenHighscores" />
</VBox>
<VBox fx:id="casinoTable" <VBox fx:id="casinoTable"
alignment="CENTER" alignment="CENTER"
styleClass="casino-table" styleClass="casino-table"
@@ -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,36 @@
<?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>
@@ -19,7 +19,7 @@
<!-- Placeholder buttons --> <!-- Placeholder buttons -->
<Button text="SETTINGS" styleClass="gray-button" /> <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 --> <!-- Buten for the Casono Browser -->
<Button text="HELP" onAction="#onBrowserButtonClick" styleClass="gray-button" /> <Button text="HELP" onAction="#onBrowserButtonClick" styleClass="gray-button" />
@@ -1,7 +1,5 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui; 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.ClientService;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import org.junit.jupiter.api.*; import org.junit.jupiter.api.*;
@@ -37,6 +37,127 @@ public class GameControllerTest {
private static final Logger LOGGER = Logger.getLogger(GameControllerTest.class.getName()); 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 * 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 * 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.playerFold(PlayerId.of("Mathis"));
game.playerCall(PlayerId.of("Jona")); callCurrent(game);
game.playerRaise(PlayerId.of("Lars"), 1200); 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(); List<Card> board = game.getCommunityCards();
assertTrue(board.size() >= 3 && board.size() <= 5); assertTrue(board.size() >= 3 && board.size() <= 5);
@@ -170,10 +294,10 @@ public class GameControllerTest {
int potBefore = game.getState().getPot().getAmount(); int potBefore = game.getState().getPot().getAmount();
game.playerRaise(PlayerId.of("Lars"), 1200); game.playerRaise(currentPlayerId(game), 1200);
game.playerCall(PlayerId.of("Julian")); callCurrent(game);
game.playerFold(PlayerId.of("Mathis")); foldCurrent(game);
game.playerCall(PlayerId.of("Jona")); callCurrent(game);
System.out.println("Pot after actions: " + game.getState().getPot().getAmount()); 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"); 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"); 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"); 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.playerCall(PlayerId.of("Jona"));
game.playerRaise(PlayerId.of("Lars"), 1200); game.playerRaise(PlayerId.of("Lars"), 1200);
game.playerCall(PlayerId.of("Julian")); assertEquals(GamePhase.FLOP, game.getState().getPhase());
game.playerCall(PlayerId.of("Jona"));
assertTrue(game.getCommunityCards().size() >= 3); assertTrue(game.getCommunityCards().size() >= 3);
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) { for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("Julian")); callCurrent(game);
} }
assertEquals(5, game.getCommunityCards().size()); assertEquals(5, game.getCommunityCards().size());
@@ -367,10 +494,14 @@ public class GameControllerTest {
assertEquals(3, game.getCommunityCards().size()); 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()); 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()); assertEquals(5, game.getCommunityCards().size());
Set<String> boardSeen = new HashSet<>(); Set<String> boardSeen = new HashSet<>();
@@ -424,10 +555,10 @@ public class GameControllerTest {
game.startGame(); game.startGame();
game.playerRaise(PlayerId.of("BigStack"), 1000); game.playerRaise(currentPlayerId(game), 1000);
game.playerCall(PlayerId.of("Caller")); callCurrent(game);
game.playerCall(PlayerId.of("MidStack")); callCurrent(game);
game.playerCall(PlayerId.of("ShortStack")); callCurrent(game);
for (Player p : game.getState().getPlayers()) { for (Player p : game.getState().getPlayers()) {
assertTrue(p.getChips() >= 0, "Negative chips detected for " + p.getId()); assertTrue(p.getChips() >= 0, "Negative chips detected for " + p.getId());
@@ -435,13 +566,13 @@ public class GameControllerTest {
assertTrue(game.getState().getPot().getAmount() > 0); assertTrue(game.getState().getPot().getAmount() > 0);
game.playerRaise(PlayerId.of("BigStack"), 100); game.playerRaise(currentPlayerId(game), 100);
int pot = game.getState().getPot().getAmount(); int pot = game.getState().getPot().getAmount();
assertTrue(pot > 0, "Pot must remain valid after re-raises"); assertTrue(pot > 0, "Pot must remain valid after re-raises");
game.playerFold(PlayerId.of("Caller")); foldCurrent(game);
game.playerFold(PlayerId.of("MidStack")); foldCurrent(game);
long activePlayers = long activePlayers =
game.getState().getPlayers().stream().filter(p -> p.getChips() > 0).count(); game.getState().getPlayers().stream().filter(p -> p.getChips() > 0).count();
@@ -451,7 +582,7 @@ public class GameControllerTest {
assertTrue(game.getCommunityCards().size() >= 3); assertTrue(game.getCommunityCards().size() >= 3);
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) { for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("BigStack")); callCurrent(game);
} }
assertEquals(5, game.getCommunityCards().size()); assertEquals(5, game.getCommunityCards().size());