Compare commits

..

43 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 6d1b467116 Fix: Sent chat messages with current username at send time 2026-04-15 21:06:13 +02:00
Jona Walpert 4687566816 Feat: Added client-side USERNAME_CHANGED event handling with whisper migration 2026-04-15 21:06:05 +02:00
Jona Walpert d27a07f3e8 Fix: Canonicalized outgoing chat sender name from authenticated session user 2026-04-15 21:05:40 +02:00
Jona Walpert b86f18a11c Feat: Added in-game player-id rename propagation for active games 2026-04-15 21:05:25 +02:00
Jona Walpert 7ff274269e Feat: Added lobby-level player rename handling and mapping synchronization 2026-04-15 21:04:57 +02:00
Jona Walpert ec916aa98c Fix: Allowed no-op username changes when new name equals current name 2026-04-15 21:04:38 +02:00
Jona Walpert 11be6e2b9b Feat: Wired lobby controller login flow to support in-session username changes 2026-04-15 20:43:15 +02:00
Jona Walpert a8599cfb4c Feat: Added CHANGE_USERNAME request support in LobbyClient 2026-04-15 20:42:50 +02:00
Jona Walpert 08b9f652be Feat: Added runtime shared-username update helper in client bootstrap 2026-04-15 20:42:28 +02:00
Jona Walpert 3252c7e534 Feat: Enabled username input section in lobby UI 2026-04-15 20:41:31 +02:00
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
40 changed files with 2516 additions and 282 deletions
@@ -70,6 +70,18 @@ This document describes the protocol for client-server communication in our appl
- [Success Response](#success-response)
- [Example Request](#example-request)
- [Example Response](#example-response)
- [GET_HIGHSCORES command](#get_highscores-command)
- [Required pre-execution checks](#required-pre-execution-checks)
- [Request Parameters](#request-parameters)
- [Success Response](#success-response)
- [Example Request](#example-request)
- [Example Response](#example-response)
- [CLEAR_HIGHSCORES command](#clear_highscores-command)
- [Required pre-execution checks](#required-pre-execution-checks)
- [Request Parameters](#request-parameters)
- [Success Response](#success-response)
- [Example Request](#example-request)
- [Example Response](#example-response)
- [RAISE command](#raise-command)
- [CALL command](#call-command)
- [FOLD command](#fold-command)
@@ -1338,3 +1350,71 @@ END
MESSAGE=Maximum number of 8 lobbies reached
END
```
## GET_HIGHSCORES command
The `GET_HIGHSCORES` command returns the stored highscore list. The server sends a `HIGHSCORES` block containing repeated `HIGHSCORE` fields. Each entry is already formatted as `timestamp | winner name`.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
| Field | Type | Description |
| :----------- | :---------------------- | :--------------------------------------- |
| `HIGHSCORES` | `Collection<Highscore>` | Collection of formatted highscore lines. |
| Fields of `Highscore` | Type | Description |
| :-------------------- | :------- | :-------------------------------------------------- |
| `HIGHSCORE` | `String` | One formatted entry in `yyyy-MM-dd HH:mm:ss | name` |
### Example Request
```
GET_HIGHSCORES
```
### Example Response
```
+OK
HIGHSCORES
HIGHSCORE='2026-04-21 15:19:23 | Jona'
HIGHSCORE='2026-04-21 16:02:11 | Lars'
END
END
```
## CLEAR_HIGHSCORES command
The `CLEAR_HIGHSCORES` command deletes all stored highscore entries on the server.
### Required pre-execution checks
None.
### Request Parameters
No parameters.
### Success Response
No response fields.
### Example Request
```
CLEAR_HIGHSCORES
```
### Example Response
```
+OK
END
```
Binary file not shown.
Binary file not shown.
@@ -39,6 +39,10 @@ public class ClientApp {
return sharedUsername;
}
public static void updateSharedUsername(String username) {
setSharedUsername(username != null && !username.isBlank() ? username.trim() : null);
}
private static void setSharedUsername(String username) {
sharedUsername = username;
LOGGER.info("sharedUsername set to '{}'", getSharedUsername());
@@ -1,15 +1,20 @@
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ChatClient;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui.ChatBoxController;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.WeakHashMap;
import java.util.function.Consumer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jspecify.annotations.Nullable;
@@ -24,7 +29,7 @@ public class ChatController {
private static final Map<ClientService, ChatController> ACTIVE_CONTROLLERS =
new WeakHashMap<>();
private final String username;
private volatile String username;
private final ClientService clientService;
@@ -37,6 +42,7 @@ public class ChatController {
private final ChatBoxController chatBoxController;
private int lobbyId = -1;
private final Timer timer;
private final Consumer<List<String>> serverEventListener;
public record ChatKey(ChatType type, @Nullable String targetUser) {
public ChatKey(ChatType type) {
@@ -71,8 +77,10 @@ public class ChatController {
localUserList = new ArrayList<>();
this.chatBoxController = new ChatBoxController(username, this);
this.logger = LogManager.getLogger(ChatController.class);
this.serverEventListener = this::handleServerEvent;
registerAsActiveController(clientService);
clientService.addEventListener(serverEventListener);
this.timer = new Timer(true);
timer.schedule(
@@ -131,6 +139,7 @@ public class ChatController {
* <p>All Messages get added to a particular {@link ChatModel}, if the checks passed.
*/
public void receiveMessage() {
String currentUsername = getCurrentUsername();
List<Message> newMessages = chatClient.getMessages();
if (!newMessages.isEmpty()) {
for (Message msg : newMessages) {
@@ -146,16 +155,17 @@ public class ChatController {
(_key) ->
new ChatModel(
ChatType.LOBBY,
username,
currentUsername,
msg.lobbyId,
null))
.addMessage(msg);
}
break;
case ChatType.WHISPER:
if (msg.target.equals(username) || msg.sender.equals(username)) {
if (msg.target.equals(currentUsername)
|| msg.sender.equals(currentUsername)) {
ChatKey key;
if (msg.target.equals(username)) {
if (msg.target.equals(currentUsername)) {
key = new ChatKey(ChatType.WHISPER, msg.sender);
} else {
key = new ChatKey(ChatType.WHISPER, msg.target);
@@ -166,7 +176,7 @@ public class ChatController {
ChatModel chatModel =
new ChatModel(
ChatType.WHISPER,
username,
currentUsername,
lobbyId,
key.targetUser());
chatBoxController.addWhisperChat(key.targetUser(), chatModel);
@@ -203,13 +213,29 @@ public class ChatController {
*/
public synchronized void checkWhisperUsers() {
List<String> users = chatClient.getUsers();
logger.info(users);
if (!users.isEmpty()) {
Set<String> remoteUsers = new HashSet<>();
String currentUsername = getCurrentUsername();
for (String user : users) {
String value = user.split("\\=")[1];
logger.info(value);
addWhisperUser(value);
if (user == null || !user.contains("=")) {
continue;
}
String value = user.split("\\=", 2)[1].trim();
if (value.isBlank() || value.equals(currentUsername)) {
continue;
}
remoteUsers.add(value);
}
for (String known : new ArrayList<>(localUserList)) {
if (!remoteUsers.contains(known)) {
localUserList.remove(known);
chatBoxController.removeWhisperUser(known);
}
}
for (String remote : remoteUsers) {
addWhisperUser(remote);
}
}
@@ -222,16 +248,110 @@ public class ChatController {
if (user == null || user.isBlank()) {
return;
}
if (!(localUserList.contains(user) || user.equals(username))) {
if (!(localUserList.contains(user) || user.equals(getCurrentUsername()))) {
localUserList.add(user);
logger.info("adding new whisper user");
chatBoxController.addWhisperUser(user);
}
}
public synchronized void updateUsername(String newUsername) {
if (newUsername == null || newUsername.isBlank()) {
return;
}
String oldUsername = this.username;
this.username = newUsername.trim();
chatBoxController.setUsername(this.username);
if (oldUsername != null && !oldUsername.equals(this.username)) {
localUserList.remove(oldUsername);
chatBoxController.removeWhisperUser(oldUsername);
}
}
private void handleServerEvent(List<String> lines) {
if (lines == null || lines.isEmpty()) {
return;
}
List<RequestParameter> params;
try {
params = ClientService.convertToRequestParameters(lines);
} catch (RuntimeException e) {
return;
}
String event = null;
String oldUsername = null;
String newUsername = null;
for (RequestParameter p : params) {
if ("EVENT".equalsIgnoreCase(p.key())) {
event = p.value();
} else if ("OLD_USERNAME".equalsIgnoreCase(p.key())) {
oldUsername = p.value();
} else if ("NEW_USERNAME".equalsIgnoreCase(p.key())) {
newUsername = p.value();
}
}
if (!"USERNAME_CHANGED".equalsIgnoreCase(event)) {
return;
}
applyUsernameMigration(oldUsername, newUsername);
}
private synchronized void applyUsernameMigration(String oldUsername, String newUsername) {
if (oldUsername == null
|| newUsername == null
|| oldUsername.isBlank()
|| newUsername.isBlank()
|| oldUsername.equals(newUsername)) {
return;
}
String currentUsername = getCurrentUsername();
if (oldUsername.equals(currentUsername)) {
updateUsername(newUsername);
}
ChatKey oldKey = new ChatKey(ChatType.WHISPER, oldUsername);
ChatKey newKey = new ChatKey(ChatType.WHISPER, newUsername);
ChatModel oldModel = chatModelMap.remove(oldKey);
ChatModel existingNewModel = chatModelMap.get(newKey);
if (oldModel != null) {
oldModel.setTarget(newUsername);
if (existingNewModel == null) {
chatModelMap.put(newKey, oldModel);
} else {
// Merge possible parallel history into the already existing new-key model.
for (Message msg : oldModel.messages) {
existingNewModel.addMessage(msg);
}
}
chatBoxController.renameWhisperUser(oldUsername, newUsername);
}
localUserList.remove(oldUsername);
chatBoxController.removeWhisperUser(oldUsername);
if (!newUsername.equals(getCurrentUsername())) {
addWhisperUser(newUsername);
}
}
public String getCurrentUsername() {
String shared = ClientApp.getSharedUsername();
if (shared != null && !shared.isBlank()) {
return shared.trim();
}
return username;
}
/** Stops polling background tasks for this chat controller instance. */
public void shutdown() {
timer.cancel();
clientService.removeEventListener(serverEventListener);
synchronized (ACTIVE_CONTROLLERS) {
if (ACTIVE_CONTROLLERS.get(clientService) == this) {
ACTIVE_CONTROLLERS.remove(clientService);
@@ -22,7 +22,7 @@ public class ChatModel {
public final String username;
/** The person to send the message to If the chat is a whisper chat */
private final String target;
private String target;
private final IntegerProperty count;
@@ -82,4 +82,8 @@ public class ChatModel {
public String getTarget() {
return target;
}
public void setTarget(String target) {
this.target = target;
}
}
@@ -10,6 +10,7 @@ import java.util.List;
public class GameState {
public List<Player> players = new ArrayList<>();
public List<Card> communityCards = new ArrayList<>();
public List<String> highscoreEntries = new ArrayList<>();
public String phase;
public int pot;
@@ -155,6 +155,10 @@ public class GameClient {
s.winnerIndex = intVal(l);
yield true;
}
case String l when l.startsWith("HIGHSCORE=") -> {
s.highscoreEntries.add(value(l));
yield true;
}
default -> false;
};
}
@@ -81,6 +81,30 @@ public class LobbyClient {
throw new RuntimeException("No LOBBY_ID in response: " + lines);
}
/**
* Fetches the global highscores from the server.
*
* @return list of formatted highscore entries, newest entries at the end
*/
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.
*
@@ -131,6 +155,29 @@ public class LobbyClient {
return new LoginResult(assigned, id);
}
/**
* Changes the username for the currently logged-in session.
*
* @param newUsername desired new username
* @return a {@link LoginResult} containing assigned username and id as returned by the server
*/
public LoginResult changeUsername(String newUsername) {
List<String> lines = client.processCommand("CHANGE_USERNAME USERNAME=" + newUsername);
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
String assigned = newUsername;
String id = null;
for (RequestParameter p : params) {
if ("USERNAME".equalsIgnoreCase(p.key())) {
assigned = p.value();
} else if ("ID".equalsIgnoreCase(p.key())) {
id = p.value();
}
}
return new LoginResult(assigned, id);
}
/**
* Request the server for the list of available lobbies.
*
@@ -60,6 +60,12 @@ public class ChatBoxController {
usernameTabMap = new HashMap<>();
}
public void setUsername(String username) {
if (username != null && !username.isBlank()) {
this.username = username.trim();
}
}
/**
* Initializes the chat interface by creating the global chat model and adding the corresponding
* "GLOBAL" tab to the interface. It also registers the global chat in the {@link
@@ -84,6 +90,11 @@ public class ChatBoxController {
public void addWhisperUser(String targetUserName) {
Platform.runLater(
() -> {
for (MenuItem existing : addWhisperChatButton.getItems()) {
if (targetUserName.equals(existing.getText())) {
return;
}
}
MenuItem menuItem = new MenuItem(targetUserName);
addWhisperChatButton.getItems().add(menuItem);
menuItem.setOnAction(
@@ -98,6 +109,54 @@ public class ChatBoxController {
});
}
public void removeWhisperUser(String targetUserName) {
Platform.runLater(
() ->
addWhisperChatButton
.getItems()
.removeIf(item -> targetUserName.equals(item.getText())));
}
public void renameWhisperUser(String oldUsername, String newUsername) {
if (oldUsername == null
|| newUsername == null
|| oldUsername.isBlank()
|| newUsername.isBlank()
|| oldUsername.equals(newUsername)) {
return;
}
Platform.runLater(
() -> {
for (MenuItem item : addWhisperChatButton.getItems()) {
if (oldUsername.equals(item.getText())) {
item.setText(newUsername);
item.setOnAction(
event ->
addWhisperChat(
newUsername,
new ChatModel(
ChatType.WHISPER,
username,
-1,
newUsername)));
break;
}
}
Tab tab = usernameTabMap.remove(oldUsername);
if (tab != null) {
tab.setText(newUsername);
usernameTabMap.put(newUsername, tab);
}
int idx = activeWhisperChats.indexOf(oldUsername);
if (idx >= 0) {
activeWhisperChats.set(idx, newUsername);
}
});
}
/**
* Creates a new private (whisper) chat model for a specific target user, registers it within
* the chat system, and opens a new chat tab.
@@ -68,11 +68,12 @@ public class ChatViewController implements Initializable {
String message = inputField.getText().trim();
if (!message.isEmpty()) {
inputField.clear();
String currentUsername = controller.getCurrentUsername();
Message msg =
new Message(
chatModel.getChattype(),
chatModel.lobbyId,
username,
currentUsername,
chatModel.getTarget(),
message);
controller.onSendToNetwork(msg);
@@ -633,13 +633,14 @@ public class CasinoGameController {
*/
private void updateTaskbar(GameState s) {
TaskbarController controller = resolveTaskbarController();
if (controller != null) {
if (gameService != null && myPlayerId != null) {
if (controller == null || myPlayerId == null) {
return;
}
if (gameService != null) {
controller.setGameService(gameService, myPlayerId);
}
controller.update(s, myPlayerId);
}
}
/**
* 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;
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.GameState;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerState;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
@@ -25,7 +28,7 @@ import org.apache.logging.log4j.Logger;
*/
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 TextField taskbarInput;
@@ -41,9 +44,13 @@ public class TaskbarController {
private double xOffset = 0;
private double yOffset = 0;
private static final double TASKBAR_SCALE = 0.95;
private static final int MIN_CREDITS = 5;
private static final int MAX_CREDITS = 100000;
private static final int CREDIT_STEP = 5;
private static final double PREFLOP_BLOCK_RATIO = 0.50;
private static final double FLOP_WARN_RATIO = 0.50;
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_GRAY_BUTTON = "gray-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_RED_INPUT = "red-input-field";
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. */
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 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) {
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
* 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) {
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
* 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) {
b.setDisable(false);
@@ -140,9 +151,9 @@ public class TaskbarController {
/**
* 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) {
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
* 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) {
b.setDisable(true);
@@ -173,9 +184,9 @@ public class TaskbarController {
/**
* 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) {
t.setDisable(true);
@@ -209,15 +220,25 @@ public class TaskbarController {
@FXML
public void initialize() {
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
* 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.
* Updates the taskbar based on the current game state and the player's status.
*
* @param state
* @param myPlayerId
* @param state The current GameState object representing the state of the game, which includes
* 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) {
@@ -227,6 +248,9 @@ public class TaskbarController {
}
this.lastState = state;
if (state.currentBet > 0) {
lastReferenceBet = state.currentBet;
}
Player me =
state.players.stream()
@@ -243,9 +267,594 @@ public class TaskbarController {
boolean isMyTurn = state.activePlayer == myIndex;
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());
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
private void onInputSubmitted(KeyEvent event) {
if (event.getCode() == KeyCode.ENTER) {
ValidationResult validation = validateTypedAmount(lastState, taskbarInput.getText());
if (!validation.valid) {
event.consume();
setBetButtonVisible(false);
return;
}
processBet();
}
}
@@ -313,16 +928,7 @@ public class TaskbarController {
/** Called when the Call button is clicked. */
@FXML
private void onInputPlayerCall() {
if (gameService == null) {
LOGGER.error("GameService not initialized");
return;
}
gameService.call();
LOGGER.info("Player CALL");
refreshGame();
submitPresetInputAndProcess("call");
}
/** Called when the Fold button is clicked. */
@@ -334,6 +940,13 @@ public class TaskbarController {
return;
}
GameState state = ensureLatestStateForAction("fold");
if (isHandFinished(state)) {
LOGGER.info("Fold ignored: hand is already finished");
update(state, myPlayerId);
return;
}
gameService.fold();
LOGGER.info("Player FOLD");
@@ -343,36 +956,25 @@ public class TaskbarController {
/** Called when the Raise button is clicked. */
@FXML
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;
}
GameState state = lastState;
if (state == null) {
try {
state = gameService.refresh();
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();
int callTarget = effectiveCallTarget(state);
int target = "raise".equals(mode) ? safeDouble(callTarget) : callTarget;
taskbarInput.setText(String.valueOf(target));
processBet();
}
/**
@@ -433,35 +1035,40 @@ public class TaskbarController {
* displayed on the console.
*/
private void processBet() {
if (gameService == null) {
LOGGER.error("Error: GameService not initialized");
return;
submitAction(ActionType.INPUT);
}
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 {
int credits = Integer.parseInt(input.trim());
if (credits >= MIN_CREDITS && credits <= MAX_CREDITS && credits % CREDIT_STEP == 0) {
gameService.bet(credits);
LOGGER.info("Bet placed: {}", credits);
taskbarInput.clear();
refreshGame();
} else {
LOGGER.error("Error: Bet must be between 5 and 100000 and multiple of 5");
GameState refreshed = gameService.refresh();
if (refreshed != null) {
lastState = refreshed;
return refreshed;
}
} catch (Exception e) {
LOGGER.error("Failed to refresh game state for {}: {}", actionName, e.getMessage());
}
} catch (NumberFormatException e) {
LOGGER.error("Error: Invalid number entered");
}
return lastState;
}
/**
@@ -474,4 +1081,47 @@ public class TaskbarController {
private void onBrowserButtonClick() {
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.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.HighscoreViewController;
import java.io.IOException;
import java.net.URL;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
@@ -20,6 +23,8 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -104,6 +109,13 @@ public class CasinomainuiController {
casinoTable.getChildren().add(gridManager.getGridPane());
gridManager.renderLobbyButtons();
String sharedUsername = ClientApp.getSharedUsername();
updateUsernameFieldPresentation(sharedUsername);
if (loginButton != null) {
loginButton.setText(
sharedUsername != null && !sharedUsername.isBlank() ? "Change Name" : "Login");
}
initializeChat(clientService);
}
@@ -156,7 +168,7 @@ public class CasinomainuiController {
/** Handles the login button action. Validates input and calls LobbyClient.login(). */
@FXML
public void handleLoginButton() {
String username = usernameField.getText();
String username = usernameField == null ? null : usernameField.getText();
if (username == null || username.isBlank()) {
showAlert("Please enter a username.");
return;
@@ -170,12 +182,63 @@ public class CasinomainuiController {
showAlert("Offline mode: cannot send login to server.");
return;
}
String trimmed = username.trim();
try {
lobbyClient.login(username);
showAlert("Login sent: " + username);
var result = lobbyClient.login(trimmed);
String assigned = result != null ? result.getUsername() : trimmed;
ClientApp.updateSharedUsername(assigned);
if (chatController != null) {
chatController.updateUsername(assigned);
}
updateUsernameFieldPresentation(assigned);
if (loginButton != null) {
loginButton.setText("Change Name");
}
return;
} catch (RuntimeException loginError) {
if (!containsProtocolError(loginError, "ALREADY_LOGGED_IN")) {
LOGGER.error("Login failed: {}", loginError.getMessage());
showAlert("Login failed: " + loginError.getMessage());
return;
}
LOGGER.info("Session already logged in, trying username change");
}
try {
var result = lobbyClient.changeUsername(trimmed);
String assigned = result != null ? result.getUsername() : trimmed;
ClientApp.updateSharedUsername(assigned);
if (chatController != null) {
chatController.updateUsername(assigned);
}
updateUsernameFieldPresentation(assigned);
if (loginButton != null) {
loginButton.setText("Change Name");
}
} catch (RuntimeException e) {
LOGGER.error("Login failed: {}", e.getMessage());
showAlert("Login failed: " + e.getMessage());
LOGGER.error("Change username failed: {}", e.getMessage());
showAlert("Change username failed: " + e.getMessage());
}
}
private boolean containsProtocolError(RuntimeException error, String code) {
String msg = error.getMessage();
return msg != null && msg.contains(code);
}
private void updateUsernameFieldPresentation(String username) {
if (usernameField == null) {
return;
}
if (username != null && !username.isBlank()) {
usernameField.setText(username.trim());
usernameField.positionCaret(usernameField.getText().length());
usernameField.setPromptText("Username");
} else {
usernameField.clear();
usernameField.setPromptText("Username");
}
}
@@ -229,4 +292,36 @@ public class CasinomainuiController {
LOGGER.error("Failed to create or add lobby: {}", e.getMessage());
}
}
/** Opens the highscore popup from the lobby UI. */
@FXML
public void handleOpenHighscores() {
try {
FXMLLoader loader =
new FXMLLoader(
getClass()
.getResource(
"/ui-structure/gameuicomponents/HighscoreView.fxml"));
Parent root = loader.load();
HighscoreViewController controller = loader.getController();
controller.setLobbyClient(lobbyClient);
controller.refreshHighscores();
Stage stage = new Stage();
stage.initStyle(StageStyle.TRANSPARENT);
stage.setTitle("Casono Highscores");
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
stage.getIcons().add(new Image(iconPath));
Scene scene = new Scene(root);
scene.setFill(javafx.scene.paint.Color.TRANSPARENT);
stage.setScene(scene);
stage.show();
stage.setAlwaysOnTop(true);
stage.toFront();
} catch (IOException e) {
LOGGER.warn("Could not open highscore window: {}", e.getMessage());
showAlert("Could not open highscore window.");
}
}
}
@@ -6,15 +6,51 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.change_username.Chang
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.check_nick.CheckUsernameRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state.GetGameStateHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state.GetGameStateParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state.GetGameStateRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_message_count.GetMessageCountRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.get_next_message.GetNextMessageRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores.ClearHighscoresHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores.ClearHighscoresParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.clear_highscores.ClearHighscoresRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores.GetHighscoresHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores.GetHighscoresParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.highscore.get_highscores.GetHighscoresRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.list_users.ListUsersRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby.CreateLobbyHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby.CreateLobbyParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby.CreateLobbyRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list.GetLobbyListHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list.GetLobbyListParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list.GetLobbyListRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status.GetLobbyStatusHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status.GetLobbyStatusParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status.GetLobbyStatusRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby.JoinLobbyHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby.JoinLobbyParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby.JoinLobbyRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game.StartGameHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game.StartGameParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game.StartGameRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.login.LoginRequest;
@@ -31,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.UserRegistry;
import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandlerExecutor;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
@@ -204,152 +241,96 @@ public class ServerApp {
ListUsersRequest.class, new ListUsersHandler(responseDispatcher, userRegistry));
// GET_LOBBY_LIST registration
parserDispatcher.register(
"GET_LOBBY_LIST",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListParser());
parserDispatcher.register("GET_LOBBY_LIST", new GetLobbyListParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_list.GetLobbyListRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_list
.GetLobbyListHandler(responseDispatcher, context.lobbyManager()));
GetLobbyListRequest.class,
(CommandHandler<GetLobbyListRequest>)
new GetLobbyListHandler(responseDispatcher, context.lobbyManager()));
// GET_GAME_STATE registration
parserDispatcher.register(
"GET_GAME_STATE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateParser());
parserDispatcher.register("GET_GAME_STATE", new GetGameStateParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game
.get_game_state.GetGameStateRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateHandler(
GetGameStateRequest.class,
(CommandHandler<GetGameStateRequest>)
new GetGameStateHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// BET registration
parserDispatcher.register(
"BET",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetParser());
parserDispatcher.register("GET_HIGHSCORES", new GetHighscoresParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet.PlayerBetRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet
.PlayerBetRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.bet
.PlayerBetHandler(
GetHighscoresRequest.class,
(CommandHandler<GetHighscoresRequest>)
new GetHighscoresHandler(responseDispatcher));
parserDispatcher.register("CLEAR_HIGHSCORES", new ClearHighscoresParser());
commandRouter.register(
ClearHighscoresRequest.class,
(CommandHandler<ClearHighscoresRequest>)
new ClearHighscoresHandler(responseDispatcher));
// BET registration
parserDispatcher.register("BET", new PlayerBetParser());
commandRouter.register(
PlayerBetRequest.class,
(CommandHandler<PlayerBetRequest>)
new PlayerBetHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// RAISE registration
parserDispatcher.register(
"RAISE",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseParser());
parserDispatcher.register("RAISE", new PlayerRaiseParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise.PlayerRaiseRequest
.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseHandler(
PlayerRaiseRequest.class,
(CommandHandler<PlayerRaiseRequest>)
new PlayerRaiseHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// CALL registration
parserDispatcher.register(
"CALL",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallParser());
parserDispatcher.register("CALL", new PlayerCallParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call.PlayerCallRequest
.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallHandler(
PlayerCallRequest.class,
(CommandHandler<PlayerCallRequest>)
new PlayerCallHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// FOLD registration
parserDispatcher.register(
"FOLD",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldParser());
parserDispatcher.register("FOLD", new PlayerFoldParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold.PlayerFoldRequest
.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldHandler(
PlayerFoldRequest.class,
(CommandHandler<PlayerFoldRequest>)
new PlayerFoldHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// GET_LOBBY_STATUS registration
parserDispatcher.register(
"GET_LOBBY_STATUS",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusParser());
parserDispatcher.register("GET_LOBBY_STATUS", new GetLobbyStatusParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.get_lobby_status
.GetLobbyStatusRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusHandler(
GetLobbyStatusRequest.class,
(CommandHandler<GetLobbyStatusRequest>)
new GetLobbyStatusHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// CREATE_LOBBY registration
parserDispatcher.register(
"CREATE_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyParser());
parserDispatcher.register("CREATE_LOBBY", new CreateLobbyParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.create_lobby.CreateLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyHandler(
CreateLobbyRequest.class,
(CommandHandler<CreateLobbyRequest>)
new CreateLobbyHandler(
responseDispatcher,
context.lobbyManager(),
context.sessionManager()));
// JOIN_LOBBY registration
parserDispatcher.register(
"JOIN_LOBBY",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyParser());
parserDispatcher.register("JOIN_LOBBY", new JoinLobbyParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyHandler(
JoinLobbyRequest.class,
(CommandHandler<JoinLobbyRequest>)
new JoinLobbyHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// START_GAME registration
parserDispatcher.register(
"START_GAME",
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameParser());
parserDispatcher.register("START_GAME", new StartGameParser());
commandRouter.register(
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameRequest.class,
(ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler<
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameHandler(
StartGameRequest.class,
(CommandHandler<StartGameRequest>)
new StartGameHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
}
}
@@ -8,6 +8,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore.HighscoreService;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
@@ -50,7 +51,11 @@ public class GetGameStateResponse extends SuccessResponse {
builder.param("CURRENT_BET", computeGlobalCurrentBet(state));
builder.param("DEALER", state.getDealerIndex());
builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex());
builder.param("WINNER", computeWinnerIndex(state, game));
int winnerIndex = computeWinnerIndex(state, game);
builder.param("WINNER", winnerIndex);
appendWinnerToHighscoresIfFinished(state, winnerIndex);
appendHighscoreEntries(builder);
appendCommunityCards(builder, state);
@@ -59,6 +64,36 @@ public class GetGameStateResponse extends SuccessResponse {
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) {
GamePhase phase = state.getPhase();
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());
}
}
@@ -9,6 +9,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.OkResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import java.util.Optional;
public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
private final UserRegistry userRegistry;
@@ -39,6 +40,11 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
@Override
public void execute(SendMessageRequest request) {
Message message = request.getMessage();
Optional<ch.unibas.dmi.dbis.cs108.casono.server.domain.user.User> senderUser =
userRegistry.getBySessionId(request.getSessionId());
if (senderUser.isPresent()) {
message.sender = senderUser.get().getName();
}
broadcast(request, message);
OkResponse response = new OkResponse(request.getContext());
responseDispatcher.dispatch(response);
@@ -10,6 +10,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Deck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine.GameEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandEvaluator;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandRank;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
@@ -61,6 +62,35 @@ public class GameController {
engine.getState().addPlayer(name, chips);
}
/**
* Renames a player id in the controller list and underlying game state.
*
* @param oldId old player id
* @param newId new player id
* @return true if rename succeeded
*/
public boolean renamePlayer(PlayerId oldId, PlayerId newId) {
if (oldId == null || newId == null) {
return false;
}
if (oldId.equals(newId)) {
return true;
}
int idx = players.indexOf(oldId);
if (idx < 0 || players.contains(newId)) {
return false;
}
boolean stateRenamed = engine.getState().renamePlayerId(oldId, newId);
if (!stateRenamed) {
return false;
}
players.set(idx, newId);
return true;
}
/**
* Initializes a new hand by preparing the deck, setting the phase to PREFLOP, rotating the
* dealer, dealing hole cards, posting blinds, and setting the first active player.
@@ -154,9 +184,22 @@ public class GameController {
*
* @param playerId The ID of the player who is folding.
*/
public void playerFold(PlayerId playerId) {
// engine.processAction(new FoldAction(PlayerId.of(playerId)));
public PlayerId playerFold(PlayerId playerId) {
engine.processAction(new FoldAction(playerId));
GameState state = engine.getState();
if (state.countNonFoldedPlayers() == 1) {
for (Player p : state.getPlayers()) {
if (!p.isFolded()) {
return p.getId();
}
}
}
return null;
}
/**
@@ -1,6 +1,9 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.Action;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.action.ActionType;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleEngine;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
@@ -80,12 +83,38 @@ public class GameEngine {
*/
public void handleAction(GameState state, Action action) {
if (action.getType() != ActionType.BLIND) {
Player currentPlayer = state.getCurrentPlayer();
PlayerId actingPlayerId = action.getPlayerId();
if (currentPlayer == null || actingPlayerId == null) {
throw new RuntimeException("Invalid turn state");
}
if (!state.isAllowOutOfTurn() && !currentPlayer.getId().equals(actingPlayerId)) {
throw new RuntimeException("Not your turn");
}
Player actingPlayer = state.getPlayer(actingPlayerId);
if (actingPlayer.isFolded()) {
throw new RuntimeException("Player already folded");
}
if (actingPlayer.isAllIn()) {
throw new RuntimeException("Player is all-in");
}
}
// 1.Check the rules
ruleEngine.validate(state, action);
// 2. Perform action
action.execute(state);
if (action.getType() != ActionType.BLIND) {
state.markActedThisRound(action.getPlayerId());
}
// 3. Next turn
turnManager.nextPlayer(state);
@@ -4,7 +4,6 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Card;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Deck;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerStatus;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import java.util.ArrayList;
@@ -27,6 +26,13 @@ public class RoundManager {
public static final int SMALL_BLIND = 100;
public static final int BIG_BLIND = 200;
/**
* Starts a new hand by resetting the game state, ensuring a deck is available, dealing hole
* cards to players, posting blinds, and setting the first player to act for the preflop phase.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to
*/
public void startNewHand(GameState state) {
// Use GameState's canonical reset
state.startNewHand();
@@ -44,12 +50,19 @@ public class RoundManager {
state.setCurrentPlayerToPreflopFirstToAct();
}
/**
* Checks if the current betting round is finished and advances the game phase if necessary.
*
* @param state The GameState object representing the current state of the game, which may be
* modified to advance the phase or end the hand.
*/
public void progressIfNeeded(GameState state) {
if (state.getPhase() == null) {
state.setPhase(GamePhase.PREFLOP);
}
if (state.countNonFoldedPlayers() == 1) {
state.setHandActive(false);
state.setPhase(GamePhase.FINISHED);
return;
}
@@ -59,27 +72,51 @@ public class RoundManager {
}
}
/**
* Determines if the current betting round is finished by checking if all active players have
* met the current bet or are all-in and by handling special cases for preflop betting rounds.
*
* @param state The GameState object representing the current state of the game, which is used
* to evaluate the betting round status.
* @return true if the betting round is finished and the game can progress to the next phase,
* false otherwise.
*/
private boolean isBettingRoundFinished(GameState state) {
int target = state.getTableState().getCurrentBet();
// A betting round only ends after every active player had at least one chance to act.
return allActivePlayersActedThisRound(state);
}
/**
* Helper method to check if all active players have acted in the current betting round. This is
* used to handle the special case of preflop rounds where no bets have been made yet, but
* players still need to have the opportunity to act.
*
* @param state The GameState object representing the current state of the game, which is used
* to check if all active players have acted in the current round.
* @return true if all active players have acted in the current round, false if there are still
* active players who have not acted yet.
*/
private boolean allActivePlayersActedThisRound(GameState state) {
for (Player p : state.getPlayers()) {
if (p == null) {
if (p == null || p.isFolded() || p.isAllIn()) {
continue;
}
// be tolerant if codebase mixes status + boolean flags
if (p.getStatus() == PlayerStatus.FOLDED || p.isFolded()) {
continue;
}
int bet = state.getCurrentBet(p.getId());
if (bet < target && !p.isAllIn()) {
if (!state.hasActedThisRound(p.getId())) {
return false;
}
}
return true;
}
/**
* Advances the game phase to the next stage (FLOP, TURN, RIVER, SHOWDOWN) based on the current
* phase.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to advance the phase and deal community cards as needed when progressing to the
* next stage of the hand.
*/
private void advancePhase(GameState state) {
switch (state.getPhase()) {
case PREFLOP -> dealFlop(state);
@@ -93,6 +130,12 @@ public class RoundManager {
}
}
/**
* Ensures that a deck of cards is available in the game state.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to include a new shuffled deck if one does not already exist.
*/
private void ensureDeck(GameState state) {
Deck deck = state.getDeck();
if (deck == null) {
@@ -102,6 +145,13 @@ public class RoundManager {
}
}
/**
* Deals hole cards to each player in the game state by drawing two cards from the deck for each
* player and assigning them as their hole cards.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to assign hole cards to each active player.
*/
private void dealHoleCards(GameState state) {
Deck deck = state.getDeck();
@@ -165,6 +215,14 @@ public class RoundManager {
state.getTableState().setCurrentBet(BIG_BLIND);
}
/**
* Deals the flop by drawing three community cards from the deck and adding them to the game
* state, then setting the game phase to FLOP and resetting bets for the new betting round.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to add three community cards for the flop, set the phase to FLOP, and reset bets
* for the new betting round.
*/
private void dealFlop(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
@@ -180,6 +238,14 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct();
}
/**
* Deals the turn by drawing one community card from the deck and adding it to the game state,
* then setting the game phase to TURN and resetting bets for the new betting round.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to add one community card for the turn, set the phase to TURN, and reset bets
* for the new betting round.
*/
private void dealTurn(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
@@ -193,6 +259,14 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct();
}
/**
* Deals the river by drawing one community card from the deck and adding it to the game state,
* then setting the game phase to RIVER and resetting bets for the new betting round.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to add one community card for the river, set the phase to RIVER, and reset bets
* for the new betting round.
*/
private void dealRiver(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
@@ -206,6 +280,12 @@ public class RoundManager {
state.setCurrentPlayerToPostflopFirstToAct();
}
/**
* Handles the showdown phase by setting the game phase to SHOWDOWN.
*
* @param state The GameState object representing the current state of the game, which will be
* modified to set the phase to SHOWDOWN.
*/
private void showdown(GameState state) {
state.setPhase(GamePhase.SHOWDOWN);
@@ -49,6 +49,16 @@ public class Player {
return id;
}
/**
* Updates the player's id. This is used when a username change is propagated into an already
* running game.
*
* @param id new player id
*/
public void setId(PlayerId id) {
this.id = id;
}
/**
* Returns the display name of the player. Currently identical to the player ID.
*
@@ -7,8 +7,10 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The GameState class encapsulates the entire state of a poker game at any given moment.
@@ -38,6 +40,7 @@ public class GameState {
private final Map<PlayerId, Integer> currentBets = new HashMap<>();
private final Map<PlayerId, Integer> playerBetCommitments = new HashMap<>();
private final Set<PlayerId> actedThisRound = new HashSet<>();
private final Map<PlayerId, List<Card>> holeCards = new HashMap<>();
private final List<Card> communityCards = new ArrayList<>();
@@ -46,7 +49,11 @@ public class GameState {
private static final int DEALER_OFFSET = 3;
// Getters
/**
* Returns the players in seating/turn order.
*
* @return a collection of players in the order they are seated/act.
*/
public Collection<Player> getPlayers() {
List<Player> ordered = new ArrayList<>(playerOrder.size());
for (PlayerId id : playerOrder) {
@@ -58,57 +65,123 @@ public class GameState {
return ordered;
}
/**
* Returns the current player whose turn it is to act.
*
* @return the current player, or null if there are no players or the index is out of bounds.
*/
public Player getCurrentPlayer() {
PlayerId id = playerOrder.get(currentPlayerIndex);
return players.get(id);
}
/**
* Returns the index of the current player in the player order list.
*
* @return the index of the current player, or 0 if there are no players.
*/
public int getCurrentPlayerIndex() {
return currentPlayerIndex;
}
/**
* Returns whether a hand is currently active (i.e., in progress).
*
* @return true if a hand is active, false otherwise.
*/
public boolean isHandActive() {
return handActive;
}
/**
* Returns the current phase of the game (e.g., PREFLOP, FLOP, TURN, RIVER).
*
* @return the current game phase.
*/
public GamePhase getPhase() {
return phase;
}
/**
* Returns the current state of the table, including information such as the current bet and pot
* size.
*
* @return the current table state.
*/
public TableState getTableState() {
return tableState;
}
/**
* Returns the current pot, which includes the total amount of chips in the pot and the
* contributions from each player.
*
* @return the current pot.
*/
public Pot getPot() {
return pot;
}
/**
* Returns the current deck of cards being used in the game.
*
* @return the current deck of cards.
*/
public Deck getDeck() {
return deck;
}
/**
* Returns the list of community cards currently on the table.
*
* @return a list of community cards, which may be empty if no cards have been dealt yet.
*/
public List<Card> getCommunityCards() {
return communityCards;
}
/** Always returns a mutable list (never null). */
/**
* Returns the hole cards for the specified player. If the player does not have hole cards yet,
* an empty list is returned and stored in the map for future reference.
*
* @param playerId the ID of the player whose hole cards are being requested.
* @return a list of hole cards for the specified player, which may be empty if the player has
* not been dealt cards yet.
*/
public List<Card> getHoleCards(PlayerId playerId) {
return holeCards.computeIfAbsent(playerId, k -> new ArrayList<>());
}
/**
* Returns the index of the dealer in the player order list.
*
* @return the index of the dealer, or 0 if there are no players.
*/
public int getDealerIndex() {
return dealerIndex;
}
/**
* Returns a map of player IDs to their respective hole cards. Each player's hole cards are
* represented as a list of Card objects. If a player does not have hole cards yet, they will be
* associated with an empty list.
*
* @return a map where the key is the player's ID and the value is a list of their hole cards.
*/
public Map<PlayerId, List<Card>> getPlayerCards() {
return holeCards;
}
/**
* Returns the number of players currently in the game, based on the size of the player order
* list.
*
* @return the number of players in the game.
*/
public int getPlayerCount() {
return players.size();
}
// Setters / Mutators
public void setCurrentPlayerIndex(int index) {
if (playerOrder.isEmpty()) {
this.currentPlayerIndex = 0;
@@ -117,6 +190,10 @@ public class GameState {
this.currentPlayerIndex = index;
}
/**
* Sets the current player index to the first player who should act preflop, which is determined
* based on the dealer index and the number of players.
*/
public void setCurrentPlayerToPreflopFirstToAct() {
int size = playerOrder.size();
if (size == 0) {
@@ -127,11 +204,16 @@ public class GameState {
// Heads-up: dealer (small blind) acts first preflop.
int first = (size == 2) ? dealerIndex : (dealerIndex + DEALER_OFFSET) % size;
currentPlayerIndex = first;
resetRoundActions();
if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer();
}
}
/**
* Sets the current player index to the first player who should act postflop, which is the
* player immediately to the left of the dealer (i.e., dealerIndex + 1).
*/
public void setCurrentPlayerToPostflopFirstToAct() {
int size = playerOrder.size();
if (size == 0) {
@@ -141,27 +223,86 @@ public class GameState {
int first = (dealerIndex + 1) % size;
currentPlayerIndex = first;
resetRoundActions();
if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer();
}
}
/**
* Marks the specified player as having acted in the current round.
*
* @param playerId the ID of the player to mark as having acted this round.
*/
public void markActedThisRound(PlayerId playerId) {
if (playerId != null) {
actedThisRound.add(playerId);
}
}
/**
* Checks if the specified player has already acted in the current round by checking if their ID
* is present in the set of players who have acted this round.
*
* @param playerId the ID of the player to check for having acted this round.
* @return true if the player has acted this round, false otherwise.
*/
public boolean hasActedThisRound(PlayerId playerId) {
return playerId != null && actedThisRound.contains(playerId);
}
/**
* Resets the tracking of which players have acted this round by clearing the set of player IDs.
*/
public void resetRoundActions() {
actedThisRound.clear();
}
/**
* Sets whether a hand is currently active (i.e., in progress).
*
* @param handActive a boolean value indicating whether a hand is active (true) or not (false).
*/
public void setHandActive(boolean handActive) {
this.handActive = handActive;
}
/**
* Sets the current phase of the game (e.g., PREFLOP, FLOP, TURN, RIVER).
*
* @param phase the GamePhase to set as the current phase of the game.
*/
public void setPhase(GamePhase phase) {
this.phase = phase;
}
/**
* Sets the index of the dealer in the player order list.
*
* @param dealerIndex the index to set as the dealer index, which should be within the bounds of
* the player order list if it is not empty.
*/
public void setDealerIndex(int dealerIndex) {
this.dealerIndex = dealerIndex;
}
/**
* Sets the current deck of cards being used in the game.
*
* @param deck the Deck object to set as the current deck of cards for the game. This should be
* a valid Deck instance that can be used for dealing cards during the game.
*/
public void setDeck(Deck deck) {
this.deck = deck;
}
/**
* Adds a new player to the game with the specified ID and initial chip count.
*
* @param id the PlayerId of the new player to add to the game.
* @param chips the initial number of chips the player has, which can be used for betting during
* the game.
*/
public void addPlayer(PlayerId id, int chips) {
Player player = new Player(id, chips);
@@ -174,18 +315,87 @@ public class GameState {
holeCards.computeIfAbsent(id, k -> new ArrayList<>());
}
// Betting
/**
* Renames a player id across all game-state structures.
*
* @param oldId existing player id
* @param newId new player id
* @return true if the rename was applied, false otherwise
*/
public synchronized boolean renamePlayerId(PlayerId oldId, PlayerId newId) {
if (oldId == null || newId == null) {
return false;
}
if (!players.containsKey(oldId)) {
return false;
}
if (oldId.equals(newId)) {
return true;
}
if (players.containsKey(newId)) {
return false;
}
Player player = players.remove(oldId);
if (player == null) {
return false;
}
player.setId(newId);
players.put(newId, player);
int idx = playerOrder.indexOf(oldId);
if (idx >= 0) {
playerOrder.set(idx, newId);
}
moveMapEntry(currentBets, oldId, newId, 0);
moveMapEntry(playerBetCommitments, oldId, newId, 0);
moveMapEntry(holeCards, oldId, newId, new ArrayList<>());
return true;
}
/**
* Helper method to move an entry in a map from an old key to a new key, with a fallback value
* if the old key is not present.
*
* @param map the map in which to move the entry, where the key is a PlayerId and the value is
* of type T.
* @param oldId the existing PlayerId key that is being renamed.
* @param newId the new PlayerId key to which the entry should be moved.
* @param fallback the value to use if the oldId is not present in the map.
* @param <T> the type of the values in the map, which can be any type that is used for
* player-related data in the game state.
*/
private <T> void moveMapEntry(
Map<PlayerId, T> map, PlayerId oldId, PlayerId newId, T fallback) {
T value = map.remove(oldId);
map.put(newId, value != null ? value : fallback);
}
// BETTING
/**
* Returns the current bet amount for the specified player.
*
* @param playerId the ID of the player whose current bet is being requested.
* @return the current bet amount for the specified player, or 0 if the player does not have a
* current bet.
*/
public int getCurrentBet(PlayerId playerId) {
return currentBets.getOrDefault(playerId, 0);
}
/**
* Sets the current bet amount for the specified player.
*
* @param playerId the ID of the player whose current bet is being set.
* @param amount the new bet amount to set for the specified player.
*/
public void setCurrentBet(PlayerId playerId, int amount) {
currentBets.put(playerId, amount);
}
/**
* Reset bets to 0 for ALL players (do not clear the map). Also resets table current bet to 0.
*/
/** Reset bets to 0 for ALL players (do not clear the map). */
public void resetBets() {
for (PlayerId id : playerOrder) {
currentBets.put(id, 0);
@@ -193,10 +403,20 @@ public class GameState {
tableState.setCurrentBet(0);
}
/**
* Adds the specified amount to the pot.
*
* @param amount the amount to add to the pot.
*/
public void addToPot(int amount) {
pot.add(amount);
}
/**
* Returns whether out-of-turn actions are allowed in the current game state.
*
* @return true if out-of-turn actions are allowed, false otherwise.
*/
public boolean isAllowOutOfTurn() {
return allowOutOfTurn;
}
@@ -205,7 +425,13 @@ public class GameState {
this.allowOutOfTurn = allowOutOfTurn;
}
// Player helpers
/**
* Retrieves the Player object associated with the given PlayerId.
*
* @param id the PlayerId of the player to retrieve.
* @return the Player object associated with the specified PlayerId, or a RuntimeException if
* the player is not found.
*/
public Player getPlayer(PlayerId id) {
Player player = players.get(id);
if (player == null) {
@@ -215,7 +441,13 @@ public class GameState {
return player;
}
// Bet commitments
/**
* Retrieves the current bet commitment for the specified player.
*
* @param playerId the ID of the player whose current bet commitment is being requested.
* @return the current bet commitment for the specified player, or 0 if the player does not have
* a bet commitment.
*/
public int getCurrentBetCommitment(PlayerId playerId) {
return playerBetCommitments.getOrDefault(playerId, 0);
}
@@ -224,7 +456,7 @@ public class GameState {
playerBetCommitments.put(playerId, amount);
}
// Cards
// CARDS
/**
* Gives (overwrites) the two hole cards for the given player. Ensures stable list identity
@@ -237,15 +469,21 @@ public class GameState {
cards.add(c2);
}
/**
* Adds a community card to the list of community cards on the table.
*
* @param card the Card object representing the community card to be added to the table.
*/
public void addCommunityCard(Card card) {
communityCards.add(card);
}
/** Resets the community cards by clearing the list of community cards on the table. */
public void resetCommunityCards() {
communityCards.clear();
}
// Turn / dealer management
/** Advances the current player index to the next player who can act. */
public void nextPlayer() {
if (playerOrder.isEmpty()) {
currentPlayerIndex = 0;
@@ -263,6 +501,14 @@ public class GameState {
} while (currentPlayerIndex != start);
}
/**
* Checks if the player at the specified index in the player order list is able to take an
* action.
*
* @param index the index of the player in the player order list to check for actionability.
* @return true if the player at the specified index can act (i.e., is not folded and not
* all-in), false otherwise.
*/
private boolean canPlayerAct(int index) {
if (index < 0 || index >= playerOrder.size()) {
return false;
@@ -277,12 +523,19 @@ public class GameState {
dealerIndex = (dealerIndex + 1) % playerOrder.size();
}
/**
* Returns the Player object representing the current dealer based on the dealer index in the
* player order list.
*
* @return the Player object representing the current dealer, or null if there are no players or
* the dealer index is out of bounds.
*/
public Player getDealer() {
PlayerId id = playerOrder.get(dealerIndex);
return players.get(id);
}
// Hand reset / lifecycle
// HAND RESET / LIFECYCLE
/**
* Starts a new hand by resetting the game state for the next round of poker.
@@ -326,6 +579,13 @@ public class GameState {
getPlayer(playerId).setFolded(true);
}
/**
* Checks if the specified player has folded by retrieving their Player object and checking
* their folded status.
*
* @param playerId the ID of the player to check for folded status.
* @return true if the player has folded, false otherwise.
*/
public boolean isFolded(PlayerId playerId) {
return getPlayer(playerId).isFolded();
}
@@ -0,0 +1,107 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/** Stores and reads persistent highscore entries in timestamp + winner format. */
public final class HighscoreService {
private static final HighscoreService INSTANCE = new HighscoreService();
private static final DateTimeFormatter DISPLAY_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
private static final int MAX_RETURNED_ENTRIES = 100;
private static final String LINE_SEPARATOR = "\t";
private final Path storagePath;
private HighscoreService() {
this.storagePath =
Path.of(System.getProperty("user.home"), ".casono", "highscores", "winners.log");
}
public static HighscoreService getInstance() {
return INSTANCE;
}
public synchronized void appendWinner(String winnerName) {
if (winnerName == null) {
return;
}
String sanitized = sanitizeName(winnerName);
if (sanitized.isEmpty()) {
return;
}
String line = Instant.now() + LINE_SEPARATOR + sanitized + System.lineSeparator();
try {
Files.createDirectories(storagePath.getParent());
Files.writeString(
storagePath,
line,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
} catch (IOException ignored) {
// Highscore persistence must never break the game state response path.
}
}
public synchronized List<String> readFormattedEntries() {
if (!Files.exists(storagePath)) {
return List.of();
}
List<String> lines;
try {
lines = Files.readAllLines(storagePath, StandardCharsets.UTF_8);
} catch (IOException ignored) {
return List.of();
}
List<String> formatted = new ArrayList<>();
for (String raw : lines) {
if (raw == null || raw.isBlank()) {
continue;
}
String[] parts = raw.split(LINE_SEPARATOR, 2);
if (parts.length < 2) {
continue;
}
try {
Instant ts = Instant.parse(parts[0].trim());
String display = DISPLAY_FORMATTER.format(ts) + " | " + parts[1].trim();
formatted.add(display);
} catch (Exception ignored) {
// Skip malformed lines and keep all valid entries.
}
}
int from = Math.max(0, formatted.size() - MAX_RETURNED_ENTRIES);
return new ArrayList<>(formatted.subList(from, formatted.size()));
}
public synchronized void clearAll() {
try {
Files.deleteIfExists(storagePath);
} catch (IOException ignored) {
// Clearing highscores must not break request handling.
}
}
private static String sanitizeName(String winnerName) {
return winnerName.trim().replace("\n", " ").replace("\r", " ").replace("\t", " ");
}
}
@@ -70,6 +70,30 @@ public class Lobby {
return playerNames.remove(playerName);
}
/**
* Renames a player in this lobby's player list.
*
* @param oldName old username
* @param newName new username
* @return true if renamed successfully
*/
public boolean renamePlayer(String oldName, String newName) {
if (oldName == null || newName == null) {
return false;
}
synchronized (playerNames) {
if (oldName.equals(newName)) {
return playerNames.contains(oldName);
}
int idx = playerNames.indexOf(oldName);
if (idx < 0 || playerNames.contains(newName)) {
return false;
}
playerNames.set(idx, newName);
return true;
}
}
public void initGame(GameController controller) {
this.gameController = controller;
}
@@ -1,5 +1,6 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby.AddResult;
import java.time.Duration;
import java.time.Instant;
@@ -170,6 +171,56 @@ public class LobbyManager {
}
}
/**
* Renames a player across lobby mapping, lobby player list and running game ids.
*
* @param oldUsername old username
* @param newUsername new username
* @return true if rename was applied
*/
public synchronized boolean renamePlayer(String oldUsername, String newUsername) {
if (oldUsername == null || newUsername == null) {
return false;
}
if (oldUsername.equals(newUsername)) {
return true;
}
LobbyId lobbyId = playerToLobby.get(oldUsername);
if (lobbyId == null) {
return true;
}
if (playerToLobby.containsKey(newUsername)) {
return false;
}
Lobby lobby = activeLobbies.get(lobbyId);
if (lobby == null) {
playerToLobby.remove(oldUsername);
return true;
}
boolean lobbyRenamed = lobby.renamePlayer(oldUsername, newUsername);
if (!lobbyRenamed) {
return false;
}
if (lobby.getGameController() != null) {
boolean gameRenamed =
lobby.getGameController()
.renamePlayer(PlayerId.of(oldUsername), PlayerId.of(newUsername));
if (!gameRenamed) {
// Best-effort rollback to keep structures consistent.
lobby.renamePlayer(newUsername, oldUsername);
return false;
}
}
playerToLobby.remove(oldUsername);
playerToLobby.put(newUsername, lobbyId);
return true;
}
/**
* Apply the given action to every player username in the lobby identified by {@code lobbyId}.
* This is a small helper that keeps iteration logic centralized and avoids leaking internal
@@ -200,6 +200,10 @@ public class UserRegistry {
return false;
}
if (user.getName().equals(newName)) {
return true;
}
if (byName.containsKey(newName)) {
return false;
}
@@ -81,25 +81,32 @@
</GridPane.margin>
</Button>
<!-- Username input and login button -->
<!-- <HBox alignment="CENTER_LEFT" spacing="10"-->
<!-- GridPane.rowIndex="0"-->
<!-- GridPane.columnIndex="0"-->
<!-- GridPane.halignment="LEFT"-->
<!-- GridPane.valignment="TOP">-->
<!-- <GridPane.margin>-->
<!-- &lt;!&ndash; place below the 'CREATE A LOBBY' button &ndash;&gt;-->
<!-- <Insets top="100" left="20" />-->
<!-- </GridPane.margin>-->
<!-- <TextField fx:id="usernameField"-->
<!-- promptText="Username"-->
<!-- styleClass="gray-input-field"-->
<!-- maxWidth="180" />-->
<!-- <Button text="Login"-->
<!-- fx:id="loginButton"-->
<!-- onAction="#handleLoginButton"-->
<!-- styleClass="button-create-lobby" />-->
<!-- </HBox>-->
<!-- Username input + change name, with highscores directly below -->
<VBox alignment="TOP_LEFT" spacing="10"
GridPane.rowIndex="0"
GridPane.columnIndex="0"
GridPane.halignment="LEFT"
GridPane.valignment="TOP">
<GridPane.margin>
<Insets top="140" left="20" />
</GridPane.margin>
<HBox alignment="CENTER_LEFT" spacing="10">
<TextField fx:id="usernameField"
promptText="Username"
styleClass="gray-input-field"
maxWidth="180" />
<Button text="Apply Name"
fx:id="loginButton"
onAction="#handleLoginButton"
styleClass="button-create-lobby" />
</HBox>
<Button text="HIGHSCORES"
styleClass="button-create-lobby"
fx:id="highscoreButton"
onAction="#handleOpenHighscores" />
</VBox>
<VBox fx:id="casinoTable"
alignment="CENTER"
@@ -0,0 +1,139 @@
.highscore-root {
-fx-background-color: rgba(13, 158, 59, 0.96);
-fx-background-radius: 16;
-fx-border-radius: 16;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
-fx-border-width: 3;
}
.highscore-title {
-fx-text-fill: #ffffff;
-fx-font-size: 24px;
-fx-font-weight: bold;
-fx-effect: dropshadow(one-pass-box, #000000, 0, 0, 2, 2);
}
.highscore-status {
-fx-text-fill: #ffffff;
-fx-font-size: 13px;
}
.list-view {
-fx-background-color: #114d26;
-fx-control-inner-background: #114d26;
-fx-background-insets: 0;
-fx-background-radius: 12;
-fx-border-radius: 12;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
-fx-border-width: 2;
}
.list-view .list-cell {
-fx-background-color: transparent;
-fx-text-fill: #ffe89a;
-fx-font-family: "Courier New";
-fx-font-size: 13px;
}
.list-view .list-cell:filled {
-fx-background-color: rgba(0, 0, 0, 0.14);
}
.list-view .list-cell:filled:hover {
-fx-background-color: rgba(0, 0, 0, 0.24);
}
.list-view .list-cell:filled:selected,
.list-view .list-cell:filled:selected:focused {
-fx-background-color: rgba(0, 0, 0, 0.35);
-fx-text-fill: #ffffff;
}
.gray-button,
.yellow-button,
.red-button {
-fx-background-radius: 10;
-fx-border-radius: 10;
-fx-font-family: "Courier New";
-fx-font-weight: bold;
-fx-border-width: 2;
-fx-padding: 6 12;
-fx-cursor: hand;
}
.gray-button {
-fx-background-color: #333333;
-fx-border-color: #666666;
-fx-text-fill: #cccccc;
}
.yellow-button {
-fx-background-color: #b8860b;
-fx-border-color: #ffd700;
-fx-text-fill: #ffffff;
}
.red-button {
-fx-background-color: #8b0000;
-fx-border-color: #ff4444;
-fx-text-fill: #ffffff;
}
.highscore-root {
-fx-background-color: #0d9e3b;
-fx-background-radius: 16;
-fx-border-radius: 16;
-fx-border-color: #5c3d10 #3d260a #3d260a #5c3d10;
-fx-border-width: 3;
}
.highscore-title {
-fx-text-fill: #ffffff;
-fx-font-size: 22px;
-fx-font-weight: bold;
}
.highscore-subtitle {
-fx-text-fill: #f2f2f2;
-fx-font-size: 13px;
}
.highscore-text-area {
-fx-control-inner-background: #1b1b1b;
-fx-text-fill: #ffffff;
-fx-font-family: "Courier New";
-fx-font-size: 14px;
-fx-border-color: #666666;
-fx-border-width: 2;
-fx-border-radius: 10;
-fx-background-radius: 10;
}
.gray-button,
.green-button,
.red-button {
-fx-background-radius: 10;
-fx-border-radius: 10;
-fx-font-family: "Courier New";
-fx-font-weight: bold;
-fx-border-width: 2;
-fx-padding: 6 14;
}
.green-button {
-fx-background-color: #1f7d33;
-fx-border-color: #35c759;
-fx-text-fill: #ffffff;
}
.red-button {
-fx-background-color: #8b0000;
-fx-border-color: #ff4444;
-fx-text-fill: #ffffff;
}
.gray-button {
-fx-background-color: #333333;
-fx-border-color: #666666;
-fx-text-fill: #cccccc;
}
@@ -0,0 +1,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 -->
<Button text="SETTINGS" styleClass="gray-button" />
<Button text="INFO" styleClass="gray-button" />
<Button text="HIGHSCORE" onAction="#onHighscoreButtonClick" styleClass="gray-button" />
<!-- Buten for the Casono Browser -->
<Button text="HELP" onAction="#onBrowserButtonClick" styleClass="gray-button" />
@@ -1,7 +1,5 @@
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 javafx.scene.layout.GridPane;
import org.junit.jupiter.api.*;
@@ -37,6 +37,127 @@ public class GameControllerTest {
private static final Logger LOGGER = Logger.getLogger(GameControllerTest.class.getName());
/**
* Helper method to retrieve the current player's ID from the game state.
*
* @param game The GameController instance from which to retrieve the current player's ID.
* @return The PlayerId of the current player in the game.
*/
private static PlayerId currentPlayerId(GameController game) {
return game.getState().getCurrentPlayer().getId();
}
/**
* Helper method to simulate a call action for the current player in the game.
*
* @param game The GameController instance on which to perform the call action for the current
* player.
*/
private static void callCurrent(GameController game) {
game.playerCall(currentPlayerId(game));
}
/**
* Helper method to simulate a fold action for the current player in the game.
*
* @param game The GameController instance on which to perform the fold action for the current
* player.
*/
private static void foldCurrent(GameController game) {
game.playerFold(currentPlayerId(game));
}
/**
* Verifies that the preflop phase ends once all active (non-folded) players have completed
* their required actions for the current betting round.
*/
@Test
public void testPreflopEndsAfterOneActionPerActivePlayer() {
GameState state = new GameState();
GameEngine engine =
new GameEngine(
state,
new RuleEngine(new ArrayList<>()),
new RoundManager(),
new TurnManager());
GameController game = new GameController(engine);
game.addPlayer(PlayerId.of("P1"), 10000);
game.addPlayer(PlayerId.of("P2"), 10000);
game.addPlayer(PlayerId.of("P3"), 10000);
game.addPlayer(PlayerId.of("P4"), 10000);
game.startGame();
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
assertEquals(0, game.getCommunityCards().size());
callCurrent(game);
callCurrent(game);
callCurrent(game);
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
assertEquals(0, game.getCommunityCards().size());
callCurrent(game);
assertEquals(GamePhase.FLOP, game.getState().getPhase());
assertEquals(3, game.getCommunityCards().size());
callCurrent(game);
callCurrent(game);
callCurrent(game);
callCurrent(game);
assertEquals(GamePhase.TURN, game.getState().getPhase());
assertEquals(4, game.getCommunityCards().size());
callCurrent(game);
callCurrent(game);
callCurrent(game);
callCurrent(game);
assertEquals(GamePhase.RIVER, game.getState().getPhase());
assertEquals(5, game.getCommunityCards().size());
}
/**
* Ensures that during the preflop phase, folded players are excluded from progression checks
* and do not block phase advancement.
*/
@Test
public void testPreflopCountsOnlyNonFoldedPlayersForProgress() {
GameState state = new GameState();
GameEngine engine =
new GameEngine(
state,
new RuleEngine(new ArrayList<>()),
new RoundManager(),
new TurnManager());
GameController game = new GameController(engine);
game.addPlayer(PlayerId.of("A"), 10000);
game.addPlayer(PlayerId.of("B"), 10000);
game.addPlayer(PlayerId.of("C"), 10000);
game.addPlayer(PlayerId.of("D"), 10000);
game.startGame();
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
callCurrent(game);
foldCurrent(game);
callCurrent(game);
assertEquals(GamePhase.PREFLOP, game.getState().getPhase());
callCurrent(game);
assertEquals(GamePhase.FLOP, game.getState().getPhase());
assertEquals(3, game.getCommunityCards().size());
}
/**
* This test simulates a specific game scenario where Julian is expected to win with a Two Pair
* hand. It sets up a fixed deck to ensure that the desired cards are dealt to the players and
@@ -83,11 +204,14 @@ public class GameControllerTest {
}
}
game.playerCall(PlayerId.of("Julian"));
callCurrent(game);
game.playerFold(PlayerId.of("Mathis"));
game.playerCall(PlayerId.of("Jona"));
callCurrent(game);
game.playerRaise(PlayerId.of("Lars"), 1200);
// New street model: once all non-folded players acted, preflop is complete.
assertEquals(GamePhase.FLOP, game.getState().getPhase());
List<Card> board = game.getCommunityCards();
assertTrue(board.size() >= 3 && board.size() <= 5);
@@ -170,10 +294,10 @@ public class GameControllerTest {
int potBefore = game.getState().getPot().getAmount();
game.playerRaise(PlayerId.of("Lars"), 1200);
game.playerCall(PlayerId.of("Julian"));
game.playerFold(PlayerId.of("Mathis"));
game.playerCall(PlayerId.of("Jona"));
game.playerRaise(currentPlayerId(game), 1200);
callCurrent(game);
foldCurrent(game);
callCurrent(game);
System.out.println("Pot after actions: " + game.getState().getPot().getAmount());
@@ -183,11 +307,15 @@ public class GameControllerTest {
assertEquals(3, game.getCommunityCards().size(), "The flop must have 3 cards");
game.playerCall(PlayerId.of("Julian"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 4; i++) {
callCurrent(game);
}
assertEquals(4, game.getCommunityCards().size(), "A turn must result in 4 cards");
game.playerCall(PlayerId.of("Jona"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size(), "The river must consist of 5 cards");
@@ -278,13 +406,12 @@ public class GameControllerTest {
game.playerCall(PlayerId.of("Jona"));
game.playerRaise(PlayerId.of("Lars"), 1200);
game.playerCall(PlayerId.of("Julian"));
game.playerCall(PlayerId.of("Jona"));
assertEquals(GamePhase.FLOP, game.getState().getPhase());
assertTrue(game.getCommunityCards().size() >= 3);
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("Julian"));
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size());
@@ -367,10 +494,14 @@ public class GameControllerTest {
assertEquals(3, game.getCommunityCards().size());
game.playerCall(PlayerId.of("Julian"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 4; i++) {
callCurrent(game);
}
assertEquals(4, game.getCommunityCards().size());
game.playerCall(PlayerId.of("Mathis"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size());
Set<String> boardSeen = new HashSet<>();
@@ -424,10 +555,10 @@ public class GameControllerTest {
game.startGame();
game.playerRaise(PlayerId.of("BigStack"), 1000);
game.playerCall(PlayerId.of("Caller"));
game.playerCall(PlayerId.of("MidStack"));
game.playerCall(PlayerId.of("ShortStack"));
game.playerRaise(currentPlayerId(game), 1000);
callCurrent(game);
callCurrent(game);
callCurrent(game);
for (Player p : game.getState().getPlayers()) {
assertTrue(p.getChips() >= 0, "Negative chips detected for " + p.getId());
@@ -435,13 +566,13 @@ public class GameControllerTest {
assertTrue(game.getState().getPot().getAmount() > 0);
game.playerRaise(PlayerId.of("BigStack"), 100);
game.playerRaise(currentPlayerId(game), 100);
int pot = game.getState().getPot().getAmount();
assertTrue(pot > 0, "Pot must remain valid after re-raises");
game.playerFold(PlayerId.of("Caller"));
game.playerFold(PlayerId.of("MidStack"));
foldCurrent(game);
foldCurrent(game);
long activePlayers =
game.getState().getPlayers().stream().filter(p -> p.getChips() > 0).count();
@@ -451,7 +582,7 @@ public class GameControllerTest {
assertTrue(game.getCommunityCards().size() >= 3);
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("BigStack"));
callCurrent(game);
}
assertEquals(5, game.getCommunityCards().size());