Merge branch 'feat/ms-4-integration' into 'main'

Feat/ms 4 integration

Closes #59, #45, #52, #72, #73, #68, #71, and #36

See merge request cs108-fs26/Gruppe-13!126
This commit was merged in pull request #282.
This commit is contained in:
Jona Walpert
2026-04-13 01:07:52 +00:00
31 changed files with 2037 additions and 964 deletions
@@ -16,7 +16,7 @@
<!-- vim-markdown-toc -->
Der Pokertisch ist ein unglaublich faszinierender Erlebnisraum, in dem man sehr viel lernen kann: über sich selbst, über andere Menschen und über Fragen wie: wie treffe ich eigentlich Entscheidungen, wie gehe ich mit Stress und Unsicherheit um und wie gut ich darin bin, mich in andere hineinzuversetzen und Situationen richtig einzuschätzen.
Der Pokertisch ist ein unglaublich faszinierender Erlebnisraum, in dem man sehr viel lernen kann: über sich selbst, über andere Menschen und über Fragen wie: Wie treffe ich eigentlich Entscheidungen, wie gehe ich mit Stress und Unsicherheit um und wie gut ich darin bin, mich in andere hineinzuversetzen und Situationen richtig einzuschätzen.
Damit Du in diesem Erlebnisraum starten kannst, ist es wie bei jedem Spiel notwendig, zuerst die Grundregeln und den Spielablauf zu verstehen.
@@ -61,7 +61,7 @@ Regel: *50 Acting in Turn 🟢*
## Beispiel Preflop
Julian schaut seine Karten an und entscheidet sich direkt für einen Call von **600 Chips**.
Julian schaut seine Karten an und entscheidet sich direkt für einen Raise von **600 Chips**.
![3. image](./images/3-1.svg)
@@ -107,7 +107,7 @@ Wieder beginnt eine neue Setzrunde.
Jona setzt diesmal **3000 Chips**. Lars bezahlt erneut (Call), weil seine Hand weiterhin gut spielbar ist.
![9. image](./images/9-1.svg)
![9. image](./images/9-1.png)
Regel: *53 Action Out of Turn 🟡*
@@ -119,7 +119,7 @@ Dies ist die letzte Entscheidung im Spiel.
Jona setzt **5000 Chips**.
![10. image](./images/10-1.svg)
![10. image](./images/10-1.png)
Lars muss jetzt entscheiden: Fold, Call oder Raise auf 10000 Chips.
@@ -131,7 +131,7 @@ Wenn nach der letzten Setzrunde noch zwei Spieler übrig sind, kommt es zum Show
Beide Spieler zeigen ihre Karten offen. Gewonnen hat die **beste 5-Karten-Kombination aus Handkarten und Gemeinschaftskarten**.
![11. image](./images/11-1.svg)
![11. image](./images/11-1.png)
Regel: *12 Cards Speak at Showdown 🟢*
Regel: *16 Face Up for All-Ins 🟢*
@@ -149,11 +149,11 @@ Je höher die Kombination, desto stärker die Hand und desto wahrscheinlicher de
Jona: 2. Paar:
![13. image](./images/13-1.svg)
![13. image](./images/13-1.png)
Lars: 1. Paar:
![14. image](./images/14-1.svg)
![14. image](./images/14-1.png)
Da zwei Paare in der Rangfolge über einem einzelnen Paar stehen, gewinnt Jona diese Runde.
@@ -10,9 +10,8 @@ import org.apache.logging.log4j.Logger;
/**
* Entry point and bootstrap helper for the Casono client application.
*
* <p>This class is responsible for two tasks: - performing an optional startup LOGIN when a
* username is supplied on the command line, and - providing a shared {@link ClientService} instance
* that the UI can reuse so the initial connection remains active.
* <p>Responsibilities: - Parse CLI args (host:port, optional username) - Store username centrally
* so UI can reuse it - Create a shared ClientService and do startup LOGIN (optional)
*/
public class ClientApp {
@@ -21,16 +20,13 @@ public class ClientApp {
/** Shared client connection used when a username is provided at startup. */
private static volatile ClientService sharedClientService;
private static volatile String sharedUsername;
/** Default constructor. */
public ClientApp() {
// Default constructor
}
/**
* Returns the shared {@link ClientService} instance created during startup, or {@code null} if
* none exists. The UI may call this to reuse the connection that already performed the initial
* LOGIN.
*/
public static ClientService getSharedClientService() {
return sharedClientService;
}
@@ -39,64 +35,89 @@ public class ClientApp {
sharedClientService = cs;
}
/**
* Starts the client application with the given address.
*
* @param arg Address in the format "ip:port".
* @throws IllegalArgumentException if the address format is invalid.
*/
public static String getSharedUsername() {
return sharedUsername;
}
private static void setSharedUsername(String username) {
sharedUsername = username;
LOGGER.info("sharedUsername set to '{}'", getSharedUsername());
}
public static void start(String arg, String username) {
String[] hostPort = parseHostAndPort(arg);
String host = hostPort[0];
int port = Integer.parseInt(hostPort[1]);
configureServerProperties(host, port);
setSharedUsername(username != null && !username.isBlank() ? username.trim() : null);
if (getSharedUsername() != null) {
performStartupLogin(host, port, username);
}
launchUI(arg, username);
}
private static String[] parseHostAndPort(String arg) {
String[] parts = arg.split(":", 2);
if (parts.length != 2) {
throw new IllegalArgumentException("Address must be in format <ip>:<port>.");
throw new IllegalArgumentException("The address must be in the format <ip>:<port>.");
}
String host = parts[0];
int port = Integer.parseInt(parts[1]);
return parts;
}
private static void configureServerProperties(String host, int port) {
LOGGER.info("You've selected the client. It will connect port {} at host {}", port, host);
System.setProperty("casono.server.host", host);
System.setProperty("casono.server.port", String.valueOf(port));
// If a username was provided, create a shared connection and perform
// the initial LOGIN asynchronously on that connection. The connection
// is intentionally NOT closed so the UI can reuse it.
if (username != null && !username.isBlank()) {
try {
ClientService clientService = new ClientService(host, port);
setSharedClientService(clientService);
}
java.util.concurrent.ExecutorService bg =
java.util.concurrent.Executors.newSingleThreadExecutor(
r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("casono-startup-login");
return t;
});
bg.submit(
() -> {
try {
LobbyClient lobbyClient = new LobbyClient(clientService);
LoginResult res = lobbyClient.login(username);
LOGGER.info(
"Assigned username='{}' id={}",
res.getUsername(),
res.getId());
} catch (RuntimeException e) {
LOGGER.warn("Startup login failed: {}", e.getMessage());
} catch (Exception e) {
LOGGER.warn("Unexpected error during startup login", e);
} finally {
bg.shutdown();
private static void performStartupLogin(String host, int port, String username) {
try {
ClientService clientService = new ClientService(host, port);
setSharedClientService(clientService);
java.util.concurrent.ExecutorService bg =
java.util.concurrent.Executors.newSingleThreadExecutor(
r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("casono-startup-login");
return t;
});
bg.submit(
() -> {
try {
LobbyClient lobbyClient = new LobbyClient(clientService);
LoginResult res = lobbyClient.login(username);
if (res != null
&& res.getUsername() != null
&& !res.getUsername().isBlank()) {
setSharedUsername(res.getUsername().trim());
}
});
} catch (RuntimeException e) {
LOGGER.warn(
"Could not establish initial connection for startup login: {}",
e.getMessage());
// UI will create its own connection when it initializes
}
}
LOGGER.info(
"Assigned username='{}' id={}",
res != null ? res.getUsername() : null,
res != null ? res.getId() : null);
} catch (RuntimeException e) {
LOGGER.warn("Startup login failed: {}", e.getMessage());
} catch (Exception e) {
LOGGER.warn("Unexpected error during startup login", e);
} finally {
bg.shutdown();
}
});
} catch (RuntimeException e) {
LOGGER.warn(
"Could not establish initial connection for startup login: {}", e.getMessage());
}
}
private static void launchUI(String arg, String username) {
if (username != null && !username.isBlank()) {
Launcher.main(new String[] {arg, username});
} else {
@@ -104,19 +125,13 @@ public class ClientApp {
}
}
/**
* Main entry point. {@code <host:port>} (e.g. {@code 127.0.0.1:1234})
*
* <p>If a username is provided the application attempts a startup LOGIN on the shared
* connection; the UI will still start and will reuse the connection when possible. The username
* is not propagated via a system property for UI display.
*/
public static void main(String[] args) {
if (args == null || args.length == 0) {
throw new IllegalArgumentException("Address argument required: <host:port>");
}
// Optional username argument
String username = args.length > 1 && args[1] != null && !args[1].isBlank() ? args[1] : null;
start(args[0], username);
}
}
@@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.WeakHashMap;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jspecify.annotations.Nullable;
@@ -19,8 +20,14 @@ import org.jspecify.annotations.Nullable;
*/
public class ChatController {
/** Ensures one active message poller per physical ClientService connection. */
private static final Map<ClientService, ChatController> ACTIVE_CONTROLLERS =
new WeakHashMap<>();
private final String username;
private final ClientService clientService;
private final ChatClient chatClient;
public ChatBoxController getChatBoxController() {
@@ -58,24 +65,43 @@ public class ChatController {
*/
public ChatController(String username, ClientService clientService) {
this.username = username;
this.clientService = clientService;
chatClient = new ChatClient(clientService);
chatModelMap = new LinkedHashMap<>();
localUserList = new ArrayList<>();
this.chatBoxController = new ChatBoxController(username, this);
this.logger = LogManager.getLogger(ChatController.class);
this.timer = new Timer();
registerAsActiveController(clientService);
this.timer = new Timer(true);
timer.schedule(
new TimerTask() {
@Override
public void run() {
receiveMessage();
checkWhisperUsers();
try {
receiveMessage();
checkWhisperUsers();
} catch (RuntimeException e) {
logger.warn("Chat refresh failed: {}", e.getMessage());
}
}
},
0,
REFRESH_TIME);
}
private void registerAsActiveController(ClientService clientService) {
synchronized (ACTIVE_CONTROLLERS) {
ChatController oldController = ACTIVE_CONTROLLERS.get(clientService);
if (oldController != null && oldController != this) {
oldController.shutdown();
logger.info("Replaced previous ChatController poller for shared ClientService");
}
ACTIVE_CONTROLLERS.put(clientService, this);
}
}
/**
* Method to be activated, if a lobby has been chosen. It will update the UI and add a new
* ChatModel to hold the Data for the Lobby Chat
@@ -84,8 +110,14 @@ public class ChatController {
*/
public void setLobbyChat(int lobbyId) {
this.lobbyId = lobbyId;
ChatKey key = new ChatKey(ChatType.LOBBY);
ChatModel existingModel = chatModelMap.get(key);
if (existingModel != null) {
existingModel.lobbyId = lobbyId;
return;
}
ChatModel lobbyChatModel = new ChatModel(ChatType.LOBBY, username, lobbyId, null);
chatModelMap.put(new ChatKey(ChatType.LOBBY), lobbyChatModel);
chatModelMap.put(key, lobbyChatModel);
this.chatBoxController.addChatTab("Lobby", lobbyChatModel);
}
@@ -107,7 +139,7 @@ public class ChatController {
chatModelMap.get(new ChatKey(ChatType.GLOBAL)).addMessage(msg);
break;
case ChatType.LOBBY:
if (msg.lobbyId == lobbyId) {
if (msg.lobbyId == getActiveLobbyChatId()) {
chatModelMap
.computeIfAbsent(
new ChatKey(ChatType.LOBBY),
@@ -147,6 +179,20 @@ public class ChatController {
}
}
/**
* Method to get the lobbyId of the currently active lobby chat, to check if incoming lobby
* messages belong to the same lobby.
*
* @return the lobbyId of the currently active lobby chat.
*/
private int getActiveLobbyChatId() {
ChatModel lobbyModel = chatModelMap.get(new ChatKey(ChatType.LOBBY));
if (lobbyModel != null) {
return lobbyModel.lobbyId;
}
return lobbyId;
}
/**
* Method to process the usernames of other users connected to the server, after they got polled
* by the {@link ChatClient}.
@@ -162,11 +208,33 @@ public class ChatController {
for (String user : users) {
String value = user.split("\\=")[1];
logger.info(value);
if (!(localUserList.contains(value) || value.equals(username))) {
localUserList.add(value);
logger.info("adding new whisper user");
chatBoxController.addWhisperUser(value);
}
addWhisperUser(value);
}
}
}
/**
* Registers a whisper target locally and updates the UI if it is a new user.
*
* @param user target username
*/
public synchronized void addWhisperUser(String user) {
if (user == null || user.isBlank()) {
return;
}
if (!(localUserList.contains(user) || user.equals(username))) {
localUserList.add(user);
logger.info("adding new whisper user");
chatBoxController.addWhisperUser(user);
}
}
/** Stops polling background tasks for this chat controller instance. */
public void shutdown() {
timer.cancel();
synchronized (ACTIVE_CONTROLLERS) {
if (ACTIVE_CONTROLLERS.get(clientService) == this) {
ACTIVE_CONTROLLERS.remove(clientService);
}
}
}
@@ -2,41 +2,72 @@ package ch.unibas.dmi.dbis.cs108.casono.client.game;
import ch.unibas.dmi.dbis.cs108.casono.client.network.GameClient;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Service class responsible for managing the game state and providing methods to interact with the
* game.
*/
public class GameService {
private static final Logger LOG = Logger.getLogger(GameService.class.getName());
private final GameClient client;
private GameState state;
/**
* Constructs a GameService with the given GameClient for communication with the server.
* Constructs a GameService with the specified GameClient.
*
* @param client The GameClient instance used to send commands and receive responses from the
* server.
* @param client The GameClient used to communicate with the server. Must not be null.
*/
public GameService(GameClient client) {
if (client == null) {
throw new IllegalArgumentException("GameClient must not be null");
}
this.client = client;
LOG.info("GameService created");
}
/**
* Refresh the game state by fetching the latest state from the server using the GameClient.
* Refreshes the game state by fetching the latest state from the server using the GameClient.
*
* @return The updated GameState object representing the current state of the game after
* refreshing.
* @return The updated GameState after refreshing.
*/
public GameState refresh() {
state = client.fetchGameState();
LOG.fine("Refreshing game state...");
GameState newState;
try {
newState = client.fetchGameState();
} catch (Exception e) {
// Shouldn't happen with the new GameClient, but keep service stable.
LOG.log(Level.WARNING, "refresh() failed: " + e.getMessage(), e);
return state;
}
if (newState == null) {
LOG.fine("No new state (null) -> keeping previous state");
return state;
}
this.state = newState;
LOG.info(
() ->
"State updated: phase="
+ state.phase
+ " pot="
+ state.pot
+ " players="
+ (state.players != null ? state.players.size() : 0)
+ " community="
+ (state.communityCards != null ? state.communityCards.size() : 0));
return state;
}
/**
* Get the current game state.
* Retrieves the current phase of the game.
*
* @return The current GameState object representing the state of the game.
* @return The current game phase as a string (e.g., "Pre-Flop", "Flop", "Turn", "River").
*/
public int getPot() {
ensureState();
@@ -44,76 +75,82 @@ public class GameService {
}
/**
* Get the current game state.
* Retrieves the current phase of the game.
*
* @return The current GameState object representing the state of the game.
* @return The current game phase as a string (e.g., "Pre-Flop", "Flop", "Turn", "River").
*/
public List<Card> getCommunityCards() {
ensureState();
return state.communityCards;
return state.communityCards != null ? state.communityCards : List.of();
}
/**
* Get the list of players in the current game state.
* Retrieves the list of players currently in the game.
*
* @return A list of Player objects representing the players in the current game state.
* @return A list of Player objects representing the players in the game. A list of Player
* objects representing the players in the game.
*/
public List<Player> getPlayers() {
ensureState();
return state.players;
return state.players != null ? state.players : List.of();
}
/** Send a CALL command to the server to indicate that the player wants to call. */
/**
* Retrieves the index of the current player whose turn it is.
*
* @return The index of the current player in the players list, or -1 if not available.
*/
public Player getWinner() {
ensureState();
if (state.winnerIndex < 0
|| state.players == null
|| state.winnerIndex >= state.players.size()) {
return null;
}
return state.players.get(state.winnerIndex);
}
/** Retrieves the current phase of the game. */
public void call() {
client.sendCall();
}
/** Send a FOLD command to the server to indicate that the player wants to fold. */
/** Retrieves the current phase of the game. */
public void fold() {
client.sendFold();
}
/**
* Send a BET command to the server to indicate that the player wants to bet with the specified
* amount.
* Retrieves the current phase of the game.
*
* @param amount The amount the player wants to bet.
* @param amount The amount to bet. Must be a positive integer.
*/
public void bet(int amount) {
client.sendBet(amount);
}
/**
* Send a RAISE command to the server to indicate that the player wants to raise to the
* specified amount.
* Retrieves the current phase of the game.
*
* @param amount The amount the player wants to raise to.
* @param amount The amount to raise. Must be a positive integer.
*/
public void raise(int amount) {
client.sendRaise(amount);
}
/**
* Get the winner of the game if there is one. If the game is still ongoing or if there is no
* winner, this method returns null.
*
* @return The Player object representing the winner of the game, or null if there is no winner
* yet.
*/
public Player getWinner() {
ensureState();
if (state.winnerIndex < 0) {
return null;
}
return state.players.get(state.winnerIndex);
}
/** Ensure that the game state has been initialized before accessing it. */
/** Ensures that the game state has been initialized before accessing it. */
private void ensureState() {
if (state == null) {
throw new IllegalStateException("Call refresh() first");
throw new IllegalStateException("GameService used before any successful refresh()");
}
}
/**
* Returns the current GameState without refreshing from the server.
*
* @return The current GameState, or null if it has not been initialized yet.
*/
public GameState peekStateOrNull() {
return state;
}
}
@@ -8,13 +8,13 @@ import java.util.List;
* dealer position, active player, community cards, and player information.
*/
public class GameState {
public List<Player> players = new ArrayList<>();
public List<Card> communityCards = new ArrayList<>();
public String phase;
public int pot;
public int currentBet;
public int dealer;
public int activePlayer;
public int winnerIndex = -1;
public List<Card> communityCards = new ArrayList<>();
public List<Player> players = new ArrayList<>();
}
@@ -79,21 +79,11 @@ public class ChatClient {
public List<String> getUsers() {
logger.info("Asking server for list of users");
List<String> users = clientService.processCommand("LIST_USERS");
List<String> parameters = new ArrayList<String>();
for (int i = 0; i < users.size(); i++) {
String line = users.get(i);
if (line.equals("USERS")) {
continue;
} else if (line.equals("END")) {
break;
}
line = line.replaceFirst("^\t", "");
if (line.equals("USER")) {
String username = users.get(i + 1);
username = username.replaceFirst("^\t", "");
username = username.replaceFirst("^\t", "");
parameters.add(username);
List<String> parameters = new ArrayList<>();
for (String line : users) {
String trimmed = line == null ? "" : line.trim();
if (trimmed.startsWith("USERNAME=")) {
parameters.add(trimmed);
}
}
return parameters;
@@ -5,7 +5,9 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.RawPacket;
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TcpTransport;
import java.io.IOException;
import java.net.Socket;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
@@ -105,50 +107,91 @@ public class ClientService {
String responseText = rp.payload();
logger.info("Raw message '{}'", responseText);
ParsedPacket parsed = parsePacketContent(responseText);
logger.debug(
"Parsed response lines (rid={}, success={}, openBlocks={}): {}",
rid,
parsed.success,
parsed.blockStack.size(),
parsed.lines);
handleParsedResponse(rid, parsed.success, parsed.lines);
}
private ParsedPacket parsePacketContent(String responseText) {
boolean hasStatus = false;
boolean success = false;
List<String> lines = new ArrayList<>();
Deque<String> blockStack = new ArrayDeque<>();
for (String rawLine : responseText.split("\\n")) {
String line = rawLine;
String trimmed = line.trim();
if (!hasStatus) {
if ("+OK".equals(line)) {
success = true;
StatusCheckResult status = checkStatus(trimmed);
if (status != null) {
success = status.success;
hasStatus = true;
continue;
}
if (line.startsWith("-ERR") || line.startsWith("-ERROR")) {
success = false;
hasStatus = true;
continue;
}
continue;
}
if ("END".equals(line)) {
if (trimmed.isEmpty()) {
continue;
}
line = line.replaceFirst("^\\s+", "");
trimmed = line.trim();
if (isContainerStart(trimmed)) {
blockStack.push(trimmed);
lines.add(trimmed);
continue;
}
if ("END".equals(trimmed)) {
if (!blockStack.isEmpty()) {
blockStack.pop();
lines.add("END");
continue;
}
break;
}
if (line.startsWith("\t")) {
line = line.substring(1);
}
lines.add(line);
}
if (!hasStatus) {
if (responseText.contains("+OK")) {
success = true;
hasStatus = true;
} else if (responseText.contains("-ERR") || responseText.contains("-ERROR")) {
success = false;
hasStatus = true;
} else {
// best effort: treat as success
success = true;
hasStatus = true;
}
success = inferStatus(responseText);
}
return new ParsedPacket(success, lines, blockStack);
}
private StatusCheckResult checkStatus(String trimmed) {
if ("+OK".equals(trimmed)) {
return new StatusCheckResult(true);
}
if (trimmed.startsWith("-ERR") || trimmed.startsWith("-ERROR")) {
return new StatusCheckResult(false);
}
return null;
}
private boolean inferStatus(String responseText) {
if (responseText.contains("+OK")) {
return true;
}
if (responseText.contains("-ERR") || responseText.contains("-ERROR")) {
return false;
}
return true;
}
private void handleParsedResponse(int rid, boolean success, List<String> lines)
throws InterruptedException {
if (rid == 0) {
for (Consumer<List<String>> l : eventListeners) {
try {
@@ -167,6 +210,21 @@ public class ClientService {
}
}
private record ParsedPacket(boolean success, List<String> lines, Deque<String> blockStack) {}
private record StatusCheckResult(boolean success) {}
private boolean isContainerStart(String token) {
return token.equals("LOBBIES")
|| token.equals("LOBBY")
|| token.equals("PLAYERS")
|| token.equals("PLAYER")
|| token.equals("USERS")
|| token.equals("USER")
|| token.equals("CARDS")
|| token.equals("CARD");
}
/**
* Constructs a ClientService in offline mode. No network connection will be attempted and calls
* to processCommand will throw a RuntimeException.
@@ -5,320 +5,277 @@ 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 java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* The GameClient class is responsible for communicating with the server to retrieve the current
* game state. It sends a command to the server and parses the response into a structured GameState
* object.
* Fetches and parses game state from server.
*
* <p>Protocol notes (based on your current server implementation): - Success replies contain:
* PHASE, POT, CURRENT_BET, DEALER, ACTIVE_PLAYER, then blocks: - CARD ... END (community cards on
* root level) - PLAYER ... (NAME/CHIPS/BET/STATE + optional CARD blocks for requesting player) ...
* END - Error replies contain: -ERR then CODE=..., MSG=..., END
*
* <p>Important: - The server does NOT wrap cards in a "CARDS" container. - CARD blocks appear
* either: - at root level -> community cards - inside PLAYER -> hole cards for that player (usually
* only for the requesting user)
*/
public class GameClient {
private static final Logger LOG = Logger.getLogger(GameClient.class.getName());
private final ClientService client;
private final int gameId;
/**
* Constructs a GameClient with the given ClientService for communication.
*
* @param client The ClientService instance used to send commands and receive responses from the
* server.
*/
public GameClient(ClientService client, int gameId) {
if (client == null) {
throw new IllegalArgumentException("ClientService must not be null");
}
this.client = client;
this.gameId = gameId;
LOG.info(() -> "GameClient initialized for gameId=" + gameId);
}
/**
* Fetch the current game state from the server by sending a "GET_GAME_STATE"
*
* @return A GameState object representing the current state of the game, as parsed
*/
public GameState fetchGameState() {
List<String> responseLines = client.processCommand("GET_GAME_STATE GAME_ID=" + gameId);
final String cmd = "GET_GAME_STATE GAME_ID=" + gameId;
if (responseLines == null || responseLines.isEmpty()) {
throw new RuntimeException("Empty server response");
List<String> lines;
try {
lines = client.processCommand(cmd);
} catch (Exception e) {
LOG.log(Level.SEVERE, "processCommand failed for " + cmd + ": " + e.getMessage(), e);
return null;
}
if (responseLines.get(0).startsWith("-ERR")) {
throw new RuntimeException("Server Error: " + String.join(" ", responseLines));
if (lines == null || lines.isEmpty()) {
LOG.warning("Empty server response for " + cmd);
return null;
}
if (!responseLines.get(0).startsWith("+OK")) {
throw new RuntimeException("Invalid response: missing +OK");
LOG.info(() -> "GET_GAME_STATE raw lines count=" + lines.size() + " lines=" + lines);
String joined = String.join("\n", lines);
if (joined.contains("-ERR") || joined.contains("-ERROR")) {
String code = extractValue(joined, "CODE");
String msg = extractValue(joined, "MSG");
LOG.info(() -> "GET_GAME_STATE returned -ERR code=" + code + " msg=" + msg);
if ("GAME_NOT_STARTED".equalsIgnoreCase(code)) {
return null;
}
return null;
}
String fullResponse = String.join("\n", responseLines);
return parse(fullResponse);
try {
GameState state = parseGameState(joined);
LOG.info(
() ->
"Parsed state: phase="
+ state.phase
+ " pot="
+ state.pot
+ " players="
+ (state.players != null ? state.players.size() : 0)
+ " community="
+ (state.communityCards != null
? state.communityCards.size()
: 0));
return state;
} catch (Exception e) {
LOG.log(Level.SEVERE, "Failed parsing game state. Payload=\n" + joined, e);
return null;
}
}
/** Send a CALL command to the server to indicate that the player wants to call */
public void sendCall() {
client.processCommand("CALL GAME_ID=" + gameId);
}
/** Send a FOLD command to the server to indicate that the player wants to fold */
public void sendFold() {
client.processCommand("FOLD GAME_ID=" + gameId);
}
/** Send a BET command to the server to indicate that the player wants to bet */
public void sendBet(int amount) {
client.processCommand("BET GAME_ID=" + gameId + " AMOUNT=" + amount);
}
/**
* Send a RAISE command to the server to indicate that the player wants to raise
*
* @param amount The amount to raise to
*/
public void sendRaise(int amount) {
client.processCommand("RAISE GAME_ID=" + gameId + " AMOUNT=" + amount);
}
/**
* Parses the raw server response string into a structured GameState object.
*
* @param input The raw server response string containing the game state information.
* @return A GameState object representing the current state of the game.
*/
private GameState parse(String input) {
if (input.startsWith("-ERR")) {
throw new RuntimeException("Server returned Error:\n" + input);
}
GameState state = new GameState();
ParserContext ctx = new ParserContext(state);
private GameState parseGameState(String input) {
GameState s = new GameState();
s.players = new ArrayList<>();
s.communityCards = new ArrayList<>();
ParseState state = new ParseState();
for (String raw : input.split("\n")) {
String line = raw.trim();
if (shouldSkip(line)) {
if (line.isEmpty() || line.startsWith("+OK")) {
continue;
}
if (handleGlobal(line, ctx)) {
continue;
}
if (handleSections(line, ctx)) {
continue;
}
if (handlePlayer(line, ctx)) {
continue;
}
if (handleCards(line, ctx)) {
continue;
if (!parseGlobalField(s, line)
&& !parsePlayerStructure(s, state, line)
&& !parseCardData(state, line)
&& !parsePlayerData(state, line)) {
LOG.fine(() -> "Ignored line: '" + line + "'");
}
}
return state;
return s;
}
/** Holds the mutable parsing state while processing the server response. */
private static class ParserContext {
GameState state;
Player currentPlayer;
Card currentCard;
boolean inPlayers;
boolean inPlayerCards;
boolean inCommunityCards;
ParserContext(GameState state) {
this.state = state;
}
private boolean parseGlobalField(GameState s, String line) {
return switch (line) {
case String l when l.startsWith("PHASE=") -> {
s.phase = value(l);
yield true;
}
case String l when l.startsWith("POT=") -> {
s.pot = intVal(l);
yield true;
}
case String l when l.startsWith("CURRENT_BET=") -> {
s.currentBet = intVal(l);
yield true;
}
case String l when l.startsWith("DEALER=") -> {
s.dealer = intVal(l);
yield true;
}
case String l when l.startsWith("ACTIVE_PLAYER=") -> {
s.activePlayer = intVal(l);
yield true;
}
case String l when l.startsWith("WINNER=") -> {
s.winnerIndex = intVal(l);
yield true;
}
default -> false;
};
}
/**
* Checks whether a line should be ignored.
*
* @param line the current input line
* @return true if the line is empty or a status message, false otherwise
*/
private boolean shouldSkip(String line) {
return line.isEmpty() || line.startsWith("+OK");
}
/**
* Parses global game state properties (e.g., phase, pot, current bet).
*
* @param line the current input line
* @param ctx the parser context containing the game state
* @return true if the line was handled, false otherwise
*/
private boolean handleGlobal(String line, ParserContext ctx) {
GameState state = ctx.state;
if (line.startsWith("PHASE=")) {
state.phase = value(line);
} else if (line.startsWith("POT=")) {
state.pot = intVal(line);
} else if (line.startsWith("CURRENT_BET=")) {
state.currentBet = intVal(line);
} else if (line.startsWith("DEALER=")) {
state.dealer = intVal(line);
} else if (line.startsWith("ACTIVE_PLAYER=")) {
state.activePlayer = intVal(line);
} else if (line.startsWith("WINNER=")) {
state.winnerIndex = intVal(line);
} else {
return false;
// throw new RuntimeException("Unknown global field: " + line);
}
return true;
}
/**
* Processes section markers such as PLAYERS, PLAYER, CARDS, and END. Updates the parser context
* accordingly.
*
* @param line the current input line
* @param ctx the parser context
* @return true if the line was handled, false otherwise
*/
private boolean handleSections(String line, ParserContext ctx) {
if (line.equals("END")) {
// ctx.inPlayers = false;
ctx.inPlayerCards = false;
ctx.inCommunityCards = false;
ctx.currentPlayer = null;
ctx.currentCard = null;
return true;
}
if (line.equals("PLAYERS")) {
ctx.inPlayers = true;
return true;
}
private boolean parsePlayerStructure(GameState s, ParseState state, String line) {
if (line.equals("PLAYER")) {
ctx.currentPlayer = new Player();
ctx.state.players.add(ctx.currentPlayer);
state.currentPlayer = new Player();
s.players.add(state.currentPlayer);
state.currentCard = null;
state.insidePlayer = true;
return true;
}
if (line.equals("CARDS")) {
if (ctx.inPlayers && ctx.currentPlayer != null) {
ctx.inPlayerCards = true;
ctx.inCommunityCards = false;
return true;
}
if (line.equals("CARD")) {
state.currentCard = new Card("", "");
if (state.insidePlayer && state.currentPlayer != null) {
state.currentPlayer.addCard(state.currentCard);
} else {
ctx.inCommunityCards = true;
ctx.inPlayerCards = false;
s.communityCards.add(state.currentCard);
}
return true;
}
if (line.equals("END")) {
if (state.currentCard != null) {
state.currentCard = null;
} else if (state.insidePlayer) {
state.insidePlayer = false;
state.currentPlayer = null;
}
return true;
}
return false;
}
private boolean parseCardData(ParseState state, String line) {
if (state.currentCard == null) {
return false;
}
if (line.startsWith("VALUE=")) {
state.currentCard.setValue(value(line));
return true;
}
if (line.startsWith("SUIT=")) {
state.currentCard.setSuit(value(line));
return true;
}
return false;
}
private boolean parsePlayerData(ParseState state, String line) {
if (state.currentPlayer == null) {
return false;
}
if (line.startsWith("USERNAME=") || line.startsWith("NAME=")) {
state.currentPlayer.setId(PlayerId.of(value(line)));
return true;
}
if (line.startsWith("CHIPS=")) {
state.currentPlayer.setChips(intVal(line));
return true;
}
if (line.startsWith("BET=")) {
state.currentPlayer.setBet(intVal(line));
return true;
}
if (line.startsWith("STATE=")) {
state.currentPlayer.setState(parseState(value(line)));
return true;
}
return false;
}
/**
* Parses properties of the current player (e.g., name, chips, bet, state).
*
* @param line the current input line
* @param ctx the parser context
* @return true if the line was handled, false otherwise
*/
private boolean handlePlayer(String line, ParserContext ctx) {
Player p = ctx.currentPlayer;
if (p == null) {
return false;
}
if (line.startsWith("NAME=")) {
p.setId(PlayerId.of(value(line)));
} else if (line.startsWith("CHIPS=")) {
p.setChips(intVal(line));
} else if (line.startsWith("BET=")) {
p.setBet(intVal(line));
} else if (line.startsWith("STATE=")) {
p.setState(parseState(value(line)));
} else {
return false;
}
return true;
private static class ParseState {
Player currentPlayer;
Card currentCard;
boolean insidePlayer;
}
/**
* Parses card-related lines and assigns cards to the current player or the community cards.
*
* @param line the current input line
* @param ctx the parser context
* @return true if the line was handled, false otherwise
*/
private boolean handleCards(String line, ParserContext ctx) {
if (line.startsWith("CARD")) {
ctx.currentCard = new Card("", "");
if (ctx.inPlayerCards && ctx.currentPlayer != null) {
ctx.currentPlayer.addCard(ctx.currentCard);
} else if (ctx.inCommunityCards) {
ctx.state.communityCards.add(ctx.currentCard);
}
return true;
}
if (line.startsWith("VALUE=") && ctx.currentCard != null) {
ctx.currentCard.setValue(value(line));
return true;
}
if (line.startsWith("SUIT=") && ctx.currentCard != null) {
ctx.currentCard.setSuit(value(line));
return true;
}
return false;
}
/**
* Helper method to extract the value from a line in the format KEY=VALUE.
*
* @param line The input line from which to extract the value, expected to be in the format
* KEY=VALUE.
* @return The extracted value part of the input line, which is the substring after the first
* '=' character.
*/
private String value(String line) {
private static String value(String line) {
return line.split("=", 2)[1];
}
/**
* Helper method to extract an integer value from a line in the format KEY=VALUE.
*
* @param line The input line from which to extract the integer value, expected to be in the
* format KEY=VALUE where VALUE is an integer.
* @return The extracted integer value from the input line, parsed from the substring after the
* first '=' character.
*/
private int intVal(String line) {
private static int intVal(String line) {
return Integer.parseInt(value(line));
}
/**
* Helper method to parse a string representation of a player's state into the corresponding
* PlayerState enum value.
*
* @param s The input string representing the player's state, expected to be one of ACTIVE,
* FOLDED, or DEALER (case-insensitive).
* @return The corresponding PlayerState enum value based on the input string. If the input does
* not match any known state, it defaults to PlayerState.ACTIVE.
*/
private PlayerState parseState(String s) {
return switch (s.toUpperCase()) {
private static PlayerState parseState(String s) {
if (s == null) {
return PlayerState.ACTIVE;
}
return switch (s.trim().toUpperCase()) {
case "ACTIVE" -> PlayerState.ACTIVE;
case "FOLDED" -> PlayerState.FOLDED;
case "DEALER" -> PlayerState.DEALER;
default -> PlayerState.ACTIVE;
};
}
private static String extractValue(String joined, String key) {
if (joined == null) {
return null;
}
for (String raw : joined.split("\n")) {
String line = raw.trim();
if (line.startsWith(key + "=")) {
return line.substring((key + "=").length()).replace("'", "");
}
}
return null;
}
}
@@ -1,6 +1,7 @@
package ch.unibas.dmi.dbis.cs108.casono.client.network;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.ArrayList;
import java.util.List;
/**
@@ -129,4 +130,80 @@ public class LobbyClient {
}
return new LoginResult(assigned, id);
}
/**
* Request the server for the list of available lobbies.
*
* @return list of LobbyInfo objects representing current lobbies
*/
public List<LobbyInfo> getLobbyList() {
List<String> lines = client.processCommand("GET_LOBBY_LIST");
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
List<LobbyInfo> result = new ArrayList<>();
Integer currentId = null;
String currentName = null;
Integer currentPlayerCount = null;
for (RequestParameter p : params) {
String key = p.key().toUpperCase();
String val = p.value();
switch (key) {
case "ID":
// If we were collecting a lobby, flush it
if (currentId != null) {
result.add(
new LobbyInfo(
currentId,
currentName,
currentPlayerCount == null ? 0 : currentPlayerCount));
currentName = null;
currentPlayerCount = null;
}
try {
currentId = Integer.parseInt(val);
} catch (NumberFormatException e) {
currentId = null;
}
break;
case "NAME":
currentName = val;
break;
case "PLAYER_COUNT":
try {
currentPlayerCount = Integer.parseInt(val);
} catch (NumberFormatException e) {
currentPlayerCount = 0;
}
break;
default:
break;
}
}
if (currentId != null) {
result.add(
new LobbyInfo(
currentId,
currentName,
currentPlayerCount == null ? 0 : currentPlayerCount));
}
return result;
}
/** Simple data holder for lobby metadata returned by the server. */
public static final class LobbyInfo {
public final int id;
public final String name;
public final int playerCount;
public LobbyInfo(int id, String name, int playerCount) {
this.id = id;
this.name = name;
this.playerCount = playerCount;
}
}
}
@@ -45,6 +45,14 @@ public class ChatBoxController {
private String ressource = "/ui-structure/components/chatui/chattab.fxml";
/**
* Constructor for the ChatBoxController, initializes the necessary fields and data structures
* for managing chat tabs and whisper chats.
*
* @param username The username of the current user.
* @param chatController The ChatController instance responsible for handling chat logic and
* communication with the server.
*/
public ChatBoxController(String username, ChatController chatController) {
this.username = username;
this.chatController = chatController;
@@ -1,20 +1,32 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Card;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState;
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.PlayerStatusController;
import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.TaskbarController;
import java.io.IOException;
import java.net.URL;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
/**
* Controller for the casino gaming area.
@@ -43,12 +55,18 @@ public class CasinoGameController {
@FXML private VBox player2;
@FXML private VBox player3;
@FXML private ImageView myDealerIcon;
@FXML private AnchorPane chatContainer;
private GameService gameService;
private PlayerId myPlayerId;
@FXML private TaskbarController taskbarIncludeController;
private TaskbarController taskbarController;
private Image dealerImage;
private javafx.animation.Timeline timeline;
private boolean gameStarted = false;
private java.util.List<String> lastCommunityKeys = java.util.List.of();
private java.util.List<String> lastMyCardKeys = java.util.List.of();
private int lastPot = Integer.MIN_VALUE;
private static final int TOTAL_SLOTS = 5;
private static final int PLAYER_SLOTS = 2;
@@ -118,12 +136,52 @@ public class CasinoGameController {
private static final long CHIP_SCALE_DURATION_MS = 220;
private static final long CHIP_DROP_DURATION_MS = 250;
private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 35L;
private static final double CHAT_WIDTH = 400;
private static final double CHAT_HEIGHT = 600;
private ChatController chatController;
private String chatUsername;
private ClientService chatClientService;
private int chatLobbyId = -1;
private boolean chatInitialized;
/** Standard constructor. Used by FXML. */
public CasinoGameController() {
// default constructor for FXML
}
/**
* Generate a unique key for a given card based on its suit and value.
*
* @param c The card for which to generate the key.
* @return A string key representing the card, formatted as "suit:value".
*/
private String cardKey(Card c) {
if (c == null) {
return "null";
}
String suit = (c.getSuit() == null) ? "" : c.getSuit().toLowerCase();
String value = (c.getValue() == null) ? "" : c.getValue().toLowerCase();
return suit + ":" + value;
}
/**
* Generate a list of unique keys for a list of cards.
*
* @param cards The list of cards for which to generate keys.
* @param slots The number of slots to generate keys for.
* @return A list of string keys representing the cards, with "null" for any empty slots.
*/
private java.util.List<String> cardKeys(java.util.List<Card> cards, int slots) {
java.util.List<String> keys = new java.util.ArrayList<>(slots);
for (int i = 0; i < slots; i++) {
Card c = (cards != null && i < cards.size()) ? cards.get(i) : null;
keys.add(cardKey(c));
}
return java.util.List.copyOf(keys);
}
/**
* Set the GameService for this controller. This method must be called before starting the UI to
* ensure that the controller has access to the game logic and can update the interface
@@ -133,6 +191,10 @@ public class CasinoGameController {
*/
public void setGameService(GameService gameService) {
this.gameService = gameService;
TaskbarController controller = resolveTaskbarController();
if (controller != null && myPlayerId != null) {
controller.setGameService(gameService, myPlayerId);
}
}
/** Set the PlayerId of the current player. */
@@ -140,6 +202,11 @@ public class CasinoGameController {
public void initialize() {
LOGGER.info("INIT UI");
taskbarController = resolveTaskbarController();
if (taskbarController != null && gameService != null && myPlayerId != null) {
taskbarController.setGameService(gameService, myPlayerId);
}
if (communityCardsBox == null) {
LOGGER.warning("communityCardsBox is NULL");
return;
@@ -172,6 +239,71 @@ public class CasinoGameController {
// empty display only (optional)
renderCommunityCards(List.of());
renderPlayerCards(List.of());
initializeChatIfPossible();
}
/**
* Set the context for the chat functionality by providing the username, ClientService, and
* lobby ID.
*
* @param username The username of the player, used for chat identification. Must not be null or
* blank.
* @param clientService The ClientService instance used for communicating with the server. Must
* not be null.
* @param lobbyId The ID of the lobby associated with the chat, used to determine the chat
* context. Must be a non-negative integer.
*/
public void setChatContext(String username, ClientService clientService, int lobbyId) {
this.chatUsername = username;
this.chatClientService = clientService;
this.chatLobbyId = lobbyId;
initializeChatIfPossible();
}
/**
* Initialize the chat UI if all necessary information (username, ClientService, and lobby ID)
* is available and the chat has not already been initialized.
*/
private void initializeChatIfPossible() {
if (chatInitialized || chatClientService == null || chatUsername == null) {
return;
}
try {
chatController = new ChatController(chatUsername, chatClientService);
URL resource = getClass().getResource("/ui-structure/components/chatui/chatbox.fxml");
FXMLLoader loader = new FXMLLoader(resource);
loader.setController(chatController.getChatBoxController());
Parent root = loader.load();
Stage chatStage = new Stage();
chatStage.setTitle("Casono");
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
chatStage.getIcons().add(new Image(iconPath));
Scene scene = new Scene(root);
chatStage.setScene(scene);
chatStage.setWidth(CHAT_WIDTH);
chatStage.setHeight(CHAT_HEIGHT);
chatStage.setOnCloseRequest(event -> chatInitialized = false);
chatStage.show();
if (chatLobbyId >= 0) {
chatController.setLobbyChat(chatLobbyId);
}
chatInitialized = true;
} catch (IOException e) {
LOGGER.warning("Could not initialize game chat UI: " + e.getMessage());
}
}
// Test method to demonstrate the UI functionality with sample data.
@@ -298,6 +430,9 @@ public class CasinoGameController {
if (timeline != null) {
timeline.stop();
}
if (chatController != null) {
chatController.shutdown();
}
}
/**
@@ -305,22 +440,38 @@ public class CasinoGameController {
*/
private void updateUI() {
java.util.concurrent.CompletableFuture.supplyAsync(
if (gameService == null) {
LOGGER.warning("GameService is null, skipping update.");
return;
}
CompletableFuture.supplyAsync(
() -> {
try {
return gameService.refresh();
} catch (Exception e) {
LOGGER.severe("GameService Error: " + e.getMessage());
LOGGER.severe("refresh failed: " + e.getMessage());
e.printStackTrace();
return null;
}
})
.thenAccept(
s -> {
if (s == null) {
state -> {
if (state == null) {
LOGGER.info("No state received yet.");
return;
}
javafx.application.Platform.runLater(() -> applyState(s));
javafx.application.Platform.runLater(
() -> {
applyState(state);
});
})
.exceptionally(
ex -> {
LOGGER.severe("UI update Error: " + ex.getMessage());
ex.printStackTrace();
return null;
});
}
@@ -332,20 +483,108 @@ public class CasinoGameController {
*/
private void applyState(GameState s) {
renderCommunityCards(s.communityCards);
renderPot(s.pot);
if (s == null) {
return;
}
updatePlayers(s.players);
updatePlayerCards(s);
LOGGER.info(
"applyState -> phase="
+ s.phase
+ " pot="
+ s.pot
+ " players="
+ (s.players != null ? s.players.size() : 0));
List<Player> players = (s.players != null) ? s.players : List.of();
List<Card> community = (s.communityCards != null) ? s.communityCards : List.of();
List<Card> myCards = getMyCards(players);
java.util.List<String> newCommunityKeys = cardKeys(community, TOTAL_SLOTS);
if (!newCommunityKeys.equals(lastCommunityKeys)) {
lastCommunityKeys = newCommunityKeys;
renderCommunityCards(community);
}
java.util.List<String> newMyCardKeys = cardKeys(myCards, PLAYER_SLOTS);
if (!newMyCardKeys.equals(lastMyCardKeys)) {
lastMyCardKeys = newMyCardKeys;
renderPlayerCards(myCards);
}
if (s.pot != lastPot) {
lastPot = s.pot;
renderPot(s.pot);
}
updatePlayers(players);
syncWhisperTargetsFromPlayers(players);
updateGameInfo(s);
highlightDealer(s);
updateTaskbar(s);
if (taskbarController != null) {
taskbarController.update(s, myPlayerId);
LOGGER.info("myPlayerId=" + myPlayerId);
for (int i = 0; i < players.size(); i++) {
Player p = players.get(i);
LOGGER.info(
"state.players["
+ i
+ "] id="
+ (p != null ? p.getId() : null)
+ " chips="
+ (p != null ? p.getChips() : null)
+ " bet="
+ (p != null ? p.getBet() : null)
+ " state="
+ (p != null ? p.getState() : null));
}
}
/**
* Synchronize the whisper chat targets with the current list of players.
*
* @param players The list of players currently in the game, used to update the whisper chat
* targets in the chat controller.
*/
private void syncWhisperTargetsFromPlayers(List<Player> players) {
if (chatController == null || players == null || players.isEmpty()) {
return;
}
Set<String> seen = new HashSet<>();
for (Player player : players) {
if (player == null) {
continue;
}
String name = player.getName();
if (name != null && !name.isBlank() && seen.add(name)) {
chatController.addWhisperUser(name);
}
}
}
/**
* Retrieve the hole cards of the current player based on their PlayerId and the list of players
* in the game state.
*
* @param players The list of players currently in the game, used to find the player matching
* myPlayerId and retrieve their hole cards.
* @return A list of Card objects representing the current player's hole cards, or an empty list
* if the player is not found or has no cards.
*/
private List<Card> getMyCards(List<Player> players) {
if (myPlayerId == null || players == null) {
return List.of();
}
for (Player p : players) {
if (p.getId() != null && p.getId().equals(myPlayerId)) {
return p.getCards() != null ? p.getCards() : List.of();
}
}
return List.of();
}
/**
* Update the player's hole cards based on the active player in the game state.
*
@@ -393,37 +632,146 @@ public class CasinoGameController {
* @param s The current game state.
*/
private void updateTaskbar(GameState s) {
if (taskbarController != null) {
taskbarController.update(s, myPlayerId);
TaskbarController controller = resolveTaskbarController();
if (controller != null) {
if (gameService != null && myPlayerId != null) {
controller.setGameService(gameService, myPlayerId);
}
controller.update(s, myPlayerId);
}
}
/**
* Update the player status components with the latest player information from the game state.
* Update the opponent player slots based on the list of players in the game state. This method
* identifies the opponents by comparing their PlayerIds with myPlayerId and updates the
* corresponding PlayerStatusControllers for each opponent slot.
*
* @param p The list of players in the current game state.
* @param players The list of players currently in the game, used to determine which players are
* opponents and update their display in the UI accordingly. If the list is null or empty,
* all opponent slots will be cleared.
*/
private void updatePlayers(List<Player> p) {
if (p == null) {
private void updatePlayers(List<Player> players) {
if (players == null || players.isEmpty()) {
clearOpponentSlots();
return;
}
if (p.size() > PLAYER_INDEX_0) {
player1Controller.setPlayer(p.get(PLAYER_INDEX_0));
java.util.List<Player> opponents = buildOpponents(players);
boolean meFound = opponents.size() != players.size();
LOGGER.info(
"updatePlayers: total="
+ players.size()
+ " myPlayerId="
+ myPlayerId
+ " meFound="
+ meFound
+ " opponents="
+ opponents.size());
setOpponent(player1Controller, opponents, 0);
setOpponent(player2Controller, opponents, 1);
setOpponent(player3Controller, opponents, 2);
safeRefresh(player1Controller);
safeRefresh(player2Controller);
safeRefresh(player3Controller);
}
/**
* Build a list of opponent players by filtering out the current player (identified by
* myPlayerId) from the provided list of players. If the current player is not found in the
* list, it assumes all players are opponents and returns the original list.
*
* @param players The list of players to filter, which may include the current player and their
* opponents. This list is typically obtained from the game state and may be null or empty,
* in which case an empty list of opponents will be returned.
* @return A list of Player objects representing the opponents, excluding the current player if
* found. If the current player is not found, the original list of players is returned as
* opponents.
*/
private java.util.List<Player> buildOpponents(List<Player> players) {
java.util.List<Player> opponents = new java.util.ArrayList<>();
boolean meFound = false;
for (Player p : players) {
if (p == null) {
continue;
}
PlayerId pid = p.getId();
boolean isMe = myPlayerId != null && pid != null && pid.equals(myPlayerId);
if (isMe) {
meFound = true;
} else {
opponents.add(p);
}
}
if (p.size() > PLAYER_INDEX_1) {
player2Controller.setPlayer(p.get(PLAYER_INDEX_1));
if (!meFound) {
opponents.clear();
opponents.addAll(players);
}
if (p.size() > PLAYER_INDEX_2) {
player3Controller.setPlayer(p.get(PLAYER_INDEX_2));
return opponents;
}
/**
* Set the opponent player information in the specified PlayerStatusController based on the
* provided list of opponents and the index of the opponent to display.
*
* @param slot The PlayerStatusController instance representing the opponent slot in the UI,
* which will be updated with the player information if available.
* @param list The list of opponent players to choose from, which is typically built by
* filtering the game state's player list to exclude the current player.
* @param index The index of the opponent in the list to display in the specified slot.
*/
private void setOpponent(PlayerStatusController slot, java.util.List<Player> list, int index) {
if (slot == null) {
return;
}
player1Controller.refresh();
player2Controller.refresh();
player3Controller.refresh();
slot.setPlayer(index < list.size() ? list.get(index) : null);
}
/**
* Safely refresh the PlayerStatusController by calling its refresh method, while catching and
* ignoring any exceptions that may occur during the refresh process.
*
* @param c The PlayerStatusController instance to refresh, which may be null.
*/
private void safeRefresh(PlayerStatusController c) {
if (c == null) {
return;
}
try {
c.refresh();
} catch (Exception ignored) {
}
}
/**
* Clear all opponent slots in the UI by setting their player information to null and refreshing
* their display.
*/
private void clearOpponentSlots() {
if (player1Controller != null) {
player1Controller.setPlayer(null);
}
if (player2Controller != null) {
player2Controller.setPlayer(null);
}
if (player3Controller != null) {
player3Controller.setPlayer(null);
}
safeRefresh(player1Controller);
safeRefresh(player2Controller);
safeRefresh(player3Controller);
}
/**
@@ -432,30 +780,69 @@ public class CasinoGameController {
* @param s The current game state containing the dealer index.
*/
private void highlightDealer(GameState s) {
if (player1Controller != null) {
player1Controller.setDealer(false);
}
player1Controller.setDealer(false);
player2Controller.setDealer(false);
player3Controller.setDealer(false);
if (player2Controller != null) {
player2Controller.setDealer(false);
}
if (player3Controller != null) {
player3Controller.setDealer(false);
}
myDealerIcon.setVisible(false);
int dealer = s.dealer;
if (s == null || s.players == null || s.players.isEmpty()) {
return;
}
if (s.dealer < 0 || s.dealer >= s.players.size()) {
return;
}
if (dealer == DEALER_PLAYER_1) {
Player dealerPlayer = s.players.get(s.dealer);
if (dealerPlayer == null || dealerPlayer.getId() == null) {
return;
}
if (myPlayerId != null && myPlayerId.equals(dealerPlayer.getId())) {
myDealerIcon.setVisible(true);
return;
}
java.util.List<Player> opponents = buildOpponents(s.players);
if (opponents.size() > 0
&& samePlayer(opponents.get(0), dealerPlayer)
&& player1Controller != null) {
player1Controller.setDealer(true);
}
if (dealer == DEALER_PLAYER_2) {
if (opponents.size() > 1
&& samePlayer(opponents.get(1), dealerPlayer)
&& player2Controller != null) {
player2Controller.setDealer(true);
}
if (dealer == DEALER_PLAYER_3) {
if (opponents.size() > 2
&& samePlayer(opponents.get(2), dealerPlayer)
&& player3Controller != null) {
player3Controller.setDealer(true);
}
}
if (dealer == DEALER_MYSELF) {
myDealerIcon.setVisible(true);
/**
* Compare two Player objects to determine if they represent the same player based on their
* PlayerIds. This method checks for null values and compares the IDs for equality.
*
* @param a The first Player object to compare, which may be null.
* @param b The second Player object to compare, which may be null.
* @return true if both Player objects are non-null and have the same non-null PlayerId, false
* otherwise.
*/
private boolean samePlayer(Player a, Player b) {
if (a == null || b == null || a.getId() == null || b.getId() == null) {
return false;
}
return a.getId().equals(b.getId());
}
/**
@@ -758,23 +1145,29 @@ public class CasinoGameController {
return BACKSIDE;
}
String rawSuit = card.getSuit();
String rawValue = card.getValue();
if (rawSuit == null || rawValue == null) {
return BACKSIDE;
}
String suit =
switch (card.getSuit().toLowerCase()) {
case "hearts" -> "heart";
case "diamonds" -> "diamond";
case "clubs", "cross" -> "cross";
case "spades", "pik" -> "pik";
default -> card.getSuit().toLowerCase();
switch (rawSuit.trim().toLowerCase()) {
case "h", "hearts", "heart" -> "heart";
case "d", "diamonds", "diamond" -> "diamond";
case "c", "clubs", "club", "cross" -> "cross";
case "s", "spades", "spade", "pik" -> "pik";
default -> rawSuit.trim().toLowerCase();
};
String value =
switch (card.getValue().toLowerCase()) {
switch (rawValue.trim().toLowerCase()) {
case "a", "ace" -> "ace";
case "k", "king" -> "king";
case "q", "queen" -> "queen";
case "j", "jack" -> "jack";
case "10", "t" -> "10";
default -> card.getValue().toLowerCase();
default -> rawValue.trim().toLowerCase();
};
String path = "/images/card-" + suit + "-" + value + "-3.png";
@@ -913,4 +1306,37 @@ public class CasinoGameController {
st.play();
}
/**
* Set the PlayerId of the current player, which is used to identify the player's hole cards and
* update the taskbar with the correct player information.
*
* @param id The PlayerId of the current player, which should match the PlayerId in the game
* state for the player's own information to be displayed correctly.
*/
public void setMyPlayerId(PlayerId id) {
this.myPlayerId = id;
TaskbarController controller = resolveTaskbarController();
if (controller != null && gameService != null && myPlayerId != null) {
controller.setGameService(gameService, myPlayerId);
}
}
/**
* Resolve the TaskbarController instance to be used for updating the taskbar with game
* information.
*
* @return The TaskbarController instance to use for taskbar updates, which may be the directly
* injected taskbarController or the one obtained from the taskbarIncludeController.
*/
private TaskbarController resolveTaskbarController() {
if (taskbarController != null) {
return taskbarController;
}
if (taskbarIncludeController != null) {
taskbarController = taskbarIncludeController;
return taskbarController;
}
return null;
}
}
@@ -1,9 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameService;
import ch.unibas.dmi.dbis.cs108.casono.client.game.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.GameClient;
import java.io.IOException;
import java.util.UUID;
import java.util.logging.Logger;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
@@ -12,50 +18,144 @@ import javafx.stage.Stage;
*
* <p>Starts the JavaFX application, loads the graphical user interface from the FXML file, and
* initializes the main stage for the game.
*
* <p>Tasks: - Loads the FXML interface "/ui-structure/Casinogameui.fxml". - Loads the application
* icon from "/images/logoinverted.png". - Starts the application in full-screen mode.
*/
public class CasinoGameUI extends Application {
private static final Logger LOG = Logger.getLogger(CasinoGameUI.class.getName());
private static ClientService clientService;
private static String username;
private static int lobbyId = -1;
private static final int DEFAULT_WIDTH = 1200;
private static final int DEFAULT_HEIGHT = 800;
private static final int GUEST_ID_LENGTH = 8;
/** default constructor */
/** Default constructor for the CasinoGameUI application. */
public CasinoGameUI() {
// default no-arg constructor
}
/**
* Sets the ClientService instance to be used by the application.
*
* @param clientService the ClientService instance to set
*/
public static void setClientService(ClientService clientService) {
CasinoGameUI.clientService = clientService;
}
/**
* Starts the main stage of the application.
* Sets the username to be used by the application.
*
* @param stage The main stage provided by the system.
* @throws IOException If the FXML file or resources cannot be loaded.
* @param username the username to set
*/
public static void setUsername(String username) {
CasinoGameUI.username = username;
}
/**
* Sets the lobby ID to be used by the application.
*
* @param lobbyId the lobby ID to set
*/
public static void setLobbyId(int lobbyId) {
CasinoGameUI.lobbyId = lobbyId;
}
/**
* The main entry point for the JavaFX application. This method is called after the application
* is
*
* @param stage the primary stage for this application, onto which the application scene can be
* set. Applications may create other stages, if needed, but they will not be primary
* stages.
* @throws IOException
*/
@Override
public void start(Stage stage) throws IOException {
if (clientService == null) {
clientService =
ch.unibas.dmi.dbis.cs108.casono.client.ClientApp.getSharedClientService();
}
if (clientService == null) {
throw new IllegalStateException(
"CasinoGameUI: clientService is null. "
+ "Call CasinoGameUI.setClientService(...)"
+ " or start via ClientApp with a shared connection.");
}
String effectiveUsername = normalize(username);
if (effectiveUsername == null) {
effectiveUsername =
normalize(ch.unibas.dmi.dbis.cs108.casono.client.ClientApp.getSharedUsername());
}
if (effectiveUsername == null) {
effectiveUsername =
"Guest-" + UUID.randomUUID().toString().substring(0, GUEST_ID_LENGTH);
}
LOG.info(
"CasinoGameUI starting: effectiveUsername='"
+ effectiveUsername
+ "', injectedUsername='"
+ username
+ "', sharedUsername='"
+ ch.unibas.dmi.dbis.cs108.casono.client.ClientApp.getSharedUsername()
+ "', hasClientService="
+ (clientService != null));
FXMLLoader fxmlLoader =
new FXMLLoader(CasinoGameUI.class.getResource("/ui-structure/Casinogameui.fxml"));
Scene scene = new Scene(fxmlLoader.load(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
Parent root = fxmlLoader.load();
CasinoGameController controller = fxmlLoader.getController();
int gameId = 1; // TODO echte gameId einsetzen
GameClient gameClient = new GameClient(clientService, gameId);
GameService gameService = new GameService(gameClient);
controller.setGameService(gameService);
controller.setMyPlayerId(PlayerId.of(effectiveUsername));
controller.setChatContext(effectiveUsername, clientService, lobbyId);
Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT);
stage.setTitle("Casono");
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
stage.getIcons().add(new javafx.scene.image.Image(iconPath));
stage.setScene(scene);
stage.setFullScreen(true);
stage.setOnHidden(e -> controller.stop());
stage.show();
controller.start();
}
/**
* Starting point of the application.
* Normalizes a string by trimming whitespace and converting blank strings to null.
*
* @param args Command line arguments.
* @param s the string to normalize
* @return the normalized string, or null if the input is null or blank
*/
private static String normalize(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isBlank() ? null : t;
}
/**
* The main method serves as the entry point for the application. It launches the JavaFX
* application.
*
* @param args command line arguments (not used)
*/
public static void main(String[] args) {
launch();
@@ -37,6 +37,7 @@ public class TaskbarController {
private GameService gameService;
private PlayerId myPlayerId;
private GameState lastState;
private double xOffset = 0;
private double yOffset = 0;
private static final double TASKBAR_SCALE = 0.95;
@@ -225,6 +226,8 @@ public class TaskbarController {
return;
}
this.lastState = state;
Player me =
state.players.stream()
.filter(p -> myPlayerId.equals(p.getId()))
@@ -341,22 +344,35 @@ public class TaskbarController {
@FXML
private void onInputPlayerRaise() {
String input = taskbarInput.getText();
try {
int amount = Integer.parseInt(input.trim());
gameService.raise(amount);
LOGGER.info("Player RAISE {}", amount);
taskbarInput.clear();
refreshGame();
} catch (NumberFormatException e) {
LOGGER.error("Invalid raise amount");
if (gameService == null) {
LOGGER.error("GameService not initialized");
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();
}
/**
@@ -1,10 +1,15 @@
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
import 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.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
@@ -31,11 +36,13 @@ public class CasinomainuiController {
@FXML private VBox casinoTable;
@FXML private TextField usernameField;
@FXML private Button loginButton;
@FXML private AnchorPane chatContainer;
private LobbyButtonTranslationManager translationManager;
private LobbyButtonGridManager gridManager;
private int nextButtonId = 1;
private LobbyClient lobbyClient;
private ChatController chatController;
/** Default constructor used by FXMLLoader. */
public CasinomainuiController() {
@@ -75,9 +82,75 @@ public class CasinomainuiController {
// LobbyClient will use the provided ClientService; in offline mode calls will
// fail with RuntimeException
lobbyClient = new LobbyClient(clientService);
// Fetch existing lobbies from server on startup so newly-created lobbies
// by other clients are immediately visible.
try {
if (!lobbyClient.getClientService().isOffline()) {
var lobbies = lobbyClient.getLobbyList();
int bid = nextButtonId;
for (var li : lobbies) {
try {
translationManager.addLobbyButton(bid++, li.id);
} catch (Exception e) {
LOGGER.warn("Could not add lobby button: {}", e.getMessage());
}
}
nextButtonId = bid;
}
} catch (RuntimeException e) {
LOGGER.warn("Failed to fetch lobby list at startup: {}", e.getMessage());
}
casinoTable.getChildren().clear();
casinoTable.getChildren().add(gridManager.getGridPane());
gridManager.renderLobbyButtons();
initializeChat(clientService);
}
/**
* Initializes the chat UI if a valid ClientService is available. If the client is offline or
* the
*
* @param clientService
*/
private void initializeChat(ClientService clientService) {
if (clientService == null || clientService.isOffline() || chatContainer == null) {
return;
}
try {
String username = resolveChatUsername();
chatController = new ChatController(username, clientService);
URL resource = getClass().getResource("/ui-structure/components/chatui/chatbox.fxml");
FXMLLoader loader = new FXMLLoader(resource);
loader.setController(chatController.getChatBoxController());
Node chatNode = loader.load();
chatContainer.getChildren().setAll(chatNode);
AnchorPane.setTopAnchor(chatNode, 0.0);
AnchorPane.setBottomAnchor(chatNode, 0.0);
AnchorPane.setLeftAnchor(chatNode, 0.0);
AnchorPane.setRightAnchor(chatNode, 0.0);
} catch (IOException e) {
LOGGER.warn("Could not initialize lobby chat UI: {}", e.getMessage());
}
}
/**
* Resolves the username to be used in the chat. It first checks for a shared username set at
* the
*
* @return The resolved username, or "Guest" if no valid username is found.
*/
private String resolveChatUsername() {
String shared = ClientApp.getSharedUsername();
if (shared != null && !shared.isBlank()) {
return shared.trim();
}
if (usernameField != null
&& usernameField.getText() != null
&& !usernameField.getText().isBlank()) {
return usernameField.getText().trim();
}
return "Guest";
}
/** Handles the login button action. Validates input and calls LobbyClient.login(). */
@@ -121,6 +194,9 @@ public class CasinomainuiController {
/** Handles the exit button action to close the application. */
@FXML
public void handleexitbutton() {
if (chatController != null) {
chatController.shutdown();
}
Platform.exit();
}
@@ -28,6 +28,8 @@ public class LobbyButtonGridManager {
private static final int REFRESH_INTERVAL_SECONDS = 5;
private static final int INITIAL_DELAY_SECONDS = 5;
private static final int COLS = 4;
private static final int MAX_BUTTONS = 8;
private static final int GUEST_ID_LENGTH = 8;
private static final Logger LOGGER = LogManager.getLogger(LobbyButtonGridManager.class);
@@ -66,40 +68,106 @@ public class LobbyButtonGridManager {
// Subscribe to server-initiated events so closed lobbies are removed
// immediately
clientService.addEventListener(
lines -> {
List<RequestParameter> params = ClientService.convertToRequestParameters(lines);
String evt = null;
String lidStr = null;
for (RequestParameter p : params) {
if ("EVENT".equalsIgnoreCase(p.key())) {
evt = p.value();
} else if ("LOBBY_ID".equalsIgnoreCase(p.key())) {
lidStr = p.value();
}
}
if (evt != null && "LOBBY_CLOSED".equalsIgnoreCase(evt) && lidStr != null) {
int lid;
try {
lid = Integer.parseInt(lidStr);
} catch (NumberFormatException ex) {
return;
}
// find button id(s) for this lobby and remove mapping
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
Integer toRemove = null;
for (Map.Entry<Integer, Integer> e : mapping.entrySet()) {
if (e.getValue() != null && e.getValue().intValue() == lid) {
toRemove = e.getKey();
break;
}
}
if (toRemove != null) {
translationManager.removeLobbyButton(toRemove);
javafx.application.Platform.runLater(this::renderLobbyButtons);
}
}
});
clientService.addEventListener(this::handleClientServiceEvent);
}
private static record EventInfo(String event, String lobbyId) {}
private EventInfo extractEventInfo(List<RequestParameter> params) {
String evt = null;
String lidStr = null;
for (RequestParameter p : params) {
if ("EVENT".equalsIgnoreCase(p.key())) {
evt = p.value();
} else if ("LOBBY_ID".equalsIgnoreCase(p.key())) {
lidStr = p.value();
}
}
return new EventInfo(evt, lidStr);
}
private Integer tryParseInt(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException ex) {
return null;
}
}
private void handleLobbyClosed(int lid) {
LOGGER.info(
"LOBBY_CLOSED event for lobby {} — mapping before: {}",
lid,
translationManager.getButtonIdToLobbyId());
// find button id(s) for this lobby and remove mapping
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
Integer toRemove = null;
for (Map.Entry<Integer, Integer> e : mapping.entrySet()) {
if (e.getValue() != null && e.getValue().intValue() == lid) {
toRemove = e.getKey();
break;
}
}
if (toRemove != null) {
LOGGER.info(
"Removing mapping for button {} -> lobby {} (LOBBY_CLOSED event)",
toRemove,
lid);
translationManager.removeLobbyButton(toRemove);
LOGGER.debug("Mapping after removal: {}", translationManager.getButtonIdToLobbyId());
javafx.application.Platform.runLater(this::renderLobbyButtons);
}
}
private void handleLobbyCreated(int lid) {
LOGGER.info(
"LOBBY_CREATED event for lobby {} — mapping before: {}",
lid,
translationManager.getButtonIdToLobbyId());
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
// If this lobby is already known, nothing to do
if (mapping.containsValue(lid)) {
LOGGER.debug("Lobby {} already mapped, skipping", lid);
return;
}
// find next free button id (1..MAX_BUTTONS)
for (int candidate = 1; candidate <= MAX_BUTTONS; candidate++) {
if (!mapping.containsKey(candidate)) {
try {
translationManager.addLobbyButton(candidate, lid);
LOGGER.info("Mapped new lobby {} to button {}", lid, candidate);
LOGGER.debug(
"Mapping after add: {}", translationManager.getButtonIdToLobbyId());
javafx.application.Platform.runLater(this::renderLobbyButtons);
break;
} catch (Exception ex) {
// grid full? try next candidate
LOGGER.debug("Could not map created lobby {}: {}", lid, ex.getMessage());
}
}
}
}
private void handleClientServiceEvent(List<String> lines) {
EventInfo info = extractEventInfo(ClientService.convertToRequestParameters(lines));
if (info.event() == null || info.lobbyId() == null) {
return;
}
Integer lid = tryParseInt(info.lobbyId());
if (lid == null) {
return;
}
if ("LOBBY_CLOSED".equalsIgnoreCase(info.event())) {
handleLobbyClosed(lid);
} else if ("LOBBY_CREATED".equalsIgnoreCase(info.event())) {
handleLobbyCreated(lid);
}
}
private void startPeriodicRefresh(long initialDelay, long period) {
@@ -110,8 +178,29 @@ public class LobbyButtonGridManager {
private void refreshMappings() {
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
// Always attempt to sync with server-side lobby list so clients that start
// after a lobby was created will discover it. This keeps the local mapping
// in sync with the server without requiring a restart.
List<LobbyClient.LobbyInfo> serverLobbies = null;
java.util.Set<Integer> serverIds = new java.util.HashSet<>();
try {
serverLobbies = lobbyClient.getLobbyList();
if (serverLobbies != null) {
for (var li : serverLobbies) {
serverIds.add(li.id);
}
}
reconcileWithServerLobbies(serverLobbies);
} catch (Exception ex) {
LOGGER.debug("Could not fetch lobby list: {}", ex.getMessage());
}
if (mapping.isEmpty()) {
return;
// mapping may still be empty after reconciliation
if (translationManager.getButtonIdToLobbyId().isEmpty()) {
return;
}
mapping = translationManager.getButtonIdToLobbyId();
}
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(mapping.entrySet());
@@ -136,15 +225,88 @@ public class LobbyButtonGridManager {
executor)
.thenAccept(
status -> {
// Do not remove a mapping just because the status fetch
// temporarily returned null. Only remove mappings when
// the server no longer lists the lobby (reconcileWithServerLobbies
// handles removals based on authoritative server list).
if (status == null) {
translationManager.removeLobbyButton(buttonId);
javafx.application.Platform.runLater(this::renderLobbyButtons);
if (!serverIds.contains(lobbyId)) {
translationManager.removeLobbyButton(buttonId);
javafx.application.Platform.runLater(
this::renderLobbyButtons);
} else {
// treat as created/running later; keep mapping
LOGGER.debug(
"Missing status for lobby {} - keeping mapping",
lobbyId);
}
}
});
}
}
private void reconcileWithServerLobbies(List<LobbyClient.LobbyInfo> serverLobbies) {
if (serverLobbies == null) {
return;
}
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
// Build set of lobby ids returned by server
java.util.Set<Integer> serverIds = new java.util.HashSet<>();
for (var li : serverLobbies) {
serverIds.add(li.id);
}
LOGGER.debug("Reconciling mappings. serverIds={} mapping={}", serverIds, mapping);
// Remove mappings for lobbies that no longer exist
java.util.List<Integer> toRemove = new java.util.ArrayList<>();
for (var e : mapping.entrySet()) {
Integer lid = e.getValue();
if (lid == null) {
continue;
}
if (!serverIds.contains(lid)) {
toRemove.add(e.getKey());
}
}
for (Integer bid : toRemove) {
LOGGER.info(
"Reconciling: removing mapping for button {} (server no longer lists lobby)",
bid);
translationManager.removeLobbyButton(bid);
}
// Add server lobbies that are not yet mapped
for (var li : serverLobbies) {
if (mapping.containsValue(li.id)) {
continue;
}
// find next free button id (1..MAX_BUTTONS)
for (int candidate = 1; candidate <= MAX_BUTTONS; candidate++) {
if (!mapping.containsKey(candidate)) {
try {
translationManager.addLobbyButton(candidate, li.id);
LOGGER.info(
"Reconciling: added mapping button {} -> lobby {}",
candidate,
li.id);
break;
} catch (Exception ex) {
// grid full? try next candidate
LOGGER.debug(
"Could not add mapping for lobby {}: {}", li.id, ex.getMessage());
}
}
}
}
if (!toRemove.isEmpty() || !serverLobbies.isEmpty()) {
javafx.application.Platform.runLater(this::renderLobbyButtons);
}
}
public void renderLobbyButtons() {
gridPane.getChildren().clear();
@@ -216,9 +378,8 @@ public class LobbyButtonGridManager {
LobbyStatus status = parseLobbyStatus(statusStr);
String path =
getImagePathForButton(
buttonId,
status == null ? LobbyStatus.CREATED : status);
getImagePathForLobby(
lobbyId, status == null ? LobbyStatus.CREATED : status);
Image img = safeLoadImage(path);
@@ -253,11 +414,11 @@ public class LobbyButtonGridManager {
}
}
private String getImagePathForButton(int buttonId, LobbyStatus status) {
private String getImagePathForLobby(int lobbyId, LobbyStatus status) {
String statusStr = status == LobbyStatus.CREATED ? "created" : "running";
return String.format(BUTTON_IMAGE_TEMPLATE, buttonId, statusStr);
return String.format(BUTTON_IMAGE_TEMPLATE, lobbyId, statusStr);
}
private Image safeLoadImage(String path) {
@@ -315,7 +476,7 @@ public class LobbyButtonGridManager {
executor)
.thenAccept(
status -> {
String path = getImagePathForButton(buttonId, status);
String path = getImagePathForLobby(lobbyId, status);
javafx.application.Platform.runLater(
() -> {
@@ -390,8 +551,27 @@ public class LobbyButtonGridManager {
});
try {
var cs = lobbyClient.getClientService();
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI
.setClientService(lobbyClient.getClientService());
.setClientService(cs);
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI.setLobbyId(
lobbyId);
String username =
ch.unibas.dmi.dbis.cs108.casono.client.ClientApp
.getSharedUsername();
if (username == null || username.isBlank()) {
username =
"Guest-"
+ java.util
.UUID
.randomUUID()
.toString()
.substring(0, GUEST_ID_LENGTH);
}
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI.setUsername(
username);
new ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI()
.start(gameStage);
@@ -54,7 +54,7 @@ public class ServerApp {
private static final int USER_CLEANUP_JOB_RECONNECT_THRESHOLD = 10;
private static final int SESSION_DISCONNECT_JOB_DELAY = 0;
private static final int SESSION_DISCONNECT_JOB_PERIOD = 2;
private static final int SESSION_DISCONNECT_JOB_TIMEOUT = 5;
private static final int SESSION_DISCONNECT_JOB_TIMEOUT = 30;
private static final int LOBBY_EXPIRY_SECONDS = 30;
private static final int LOBBY_CLEANUP_INITIAL_DELAY_SECONDS = 5;
private static final int LOBBY_CLEANUP_PERIOD_SECONDS = 5;
@@ -94,7 +94,12 @@ public class ServerApp {
TimeUnit.SECONDS);
LobbyManager lobbyManager = new LobbyManager();
registerCommands(dispatcher, router, responseDispatcher, userRegistry, lobbyManager);
registerCommands(
dispatcher,
router,
responseDispatcher,
userRegistry,
new CommandContext(lobbyManager, sessionManager));
// Periodic cleanup: remove empty lobbies older than 30s and notify affected
// users
@@ -135,6 +140,9 @@ public class ServerApp {
networkManager.start();
}
private static record CommandContext(
LobbyManager lobbyManager, SessionManager sessionManager) {}
/**
* Registers command parsers and handlers.
*
@@ -147,7 +155,7 @@ public class ServerApp {
CommandRouter commandRouter,
ResponseDispatcher responseDispatcher,
UserRegistry userRegistry,
LobbyManager lobbyManager) {
CommandContext context) {
parserDispatcher.register("PING", new PingParser());
commandRouter.register(PingRequest.class, new PingHandler(responseDispatcher));
@@ -166,7 +174,8 @@ public class ServerApp {
parserDispatcher.register("SEND_MESSAGE", new SendMessageParser());
commandRouter.register(
SendMessageRequest.class, new SendMessageHandler(responseDispatcher, userRegistry));
SendMessageRequest.class,
new SendMessageHandler(responseDispatcher, userRegistry, context.lobbyManager()));
parserDispatcher.register("GET_MESSAGE_COUNT", new GetMessageCountParser());
commandRouter.register(
@@ -194,7 +203,7 @@ public class ServerApp {
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, lobbyManager));
.GetLobbyListHandler(responseDispatcher, context.lobbyManager()));
// GET_GAME_STATE registration
parserDispatcher.register(
@@ -209,7 +218,7 @@ public class ServerApp {
.get_game_state.GetGameStateRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.get_game_state
.GetGameStateHandler(
responseDispatcher, lobbyManager, userRegistry));
responseDispatcher, context.lobbyManager(), userRegistry));
// BET registration
parserDispatcher.register(
@@ -221,7 +230,8 @@ public class ServerApp {
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(responseDispatcher, userRegistry, lobbyManager));
.PlayerBetHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// RAISE registration
parserDispatcher.register(
@@ -236,7 +246,7 @@ public class ServerApp {
.PlayerRaiseRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.raise
.PlayerRaiseHandler(
responseDispatcher, userRegistry, lobbyManager));
responseDispatcher, userRegistry, context.lobbyManager()));
// CALL registration
parserDispatcher.register(
@@ -250,7 +260,8 @@ public class ServerApp {
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.call
.PlayerCallHandler(responseDispatcher, userRegistry, lobbyManager));
.PlayerCallHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// FOLD registration
parserDispatcher.register(
@@ -264,7 +275,8 @@ public class ServerApp {
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.game.fold
.PlayerFoldHandler(responseDispatcher, userRegistry, lobbyManager));
.PlayerFoldHandler(
responseDispatcher, userRegistry, context.lobbyManager()));
// GET_LOBBY_STATUS registration
parserDispatcher.register(
@@ -279,7 +291,7 @@ public class ServerApp {
.get_lobby_status.GetLobbyStatusRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.get_lobby_status.GetLobbyStatusHandler(
responseDispatcher, lobbyManager, userRegistry));
responseDispatcher, context.lobbyManager(), userRegistry));
// CREATE_LOBBY registration
parserDispatcher.register(
@@ -293,7 +305,10 @@ public class ServerApp {
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby
.create_lobby.CreateLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby
.CreateLobbyHandler(responseDispatcher, lobbyManager));
.CreateLobbyHandler(
responseDispatcher,
context.lobbyManager(),
context.sessionManager()));
// JOIN_LOBBY registration
parserDispatcher.register(
@@ -307,7 +322,8 @@ public class ServerApp {
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.join_lobby
.JoinLobbyHandler(responseDispatcher, lobbyManager, userRegistry));
.JoinLobbyHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
// START_GAME registration
parserDispatcher.register(
@@ -321,6 +337,7 @@ public class ServerApp {
ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameRequest>)
new ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.start_game
.StartGameHandler(responseDispatcher, lobbyManager, userRegistry));
.StartGameHandler(
responseDispatcher, context.lobbyManager(), userRegistry));
}
}
@@ -28,9 +28,9 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
@Override
public void execute(GetGameStateRequest request) {
Integer gameId = request.getGameId();
String username = request.getUsername();
String username = resolveUsername(request);
Lobby lobby = null;
Lobby lobby;
if (gameId != null) {
lobby = lobbyManager.getLobby(LobbyId.of(gameId));
if (lobby == null) {
@@ -71,4 +71,18 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
responseDispatcher.dispatch(new GetGameStateResponse(request.getContext(), game, username));
}
private String resolveUsername(GetGameStateRequest request) {
String username = request.getUsername();
if (username != null && !username.isBlank()) {
return username.trim();
}
return userRegistry
.getBySessionId(request.getSessionId())
.map(User::getName)
.map(String::trim)
.filter(name -> !name.isEmpty())
.orElse(null);
}
}
@@ -12,25 +12,44 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessR
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBodyBuilder;
import java.util.List;
import java.util.Objects;
/** Response carrying a snapshot of the current game state using the project's wire format. */
/**
* Response carrying a snapshot of the current game state using the project's wire format.
*
* <p>Wire format (relevant parts):
*
* <p>+OK PHASE=... POT=... CURRENT_BET=... DEALER=... ACTIVE_PLAYER=... CARD VALUE=... SUIT=... END
* PLAYER NAME=... CHIPS=... BET=... STATE=... CARD (only for requesting user) VALUE=... SUIT=...
* END END END
*
* <p>Notes: - Community cards are always emitted at top-level (0..5). If none exist (e.g. PREFLOP),
* none are emitted. - Hole cards are only emitted for the requesting player (privacy).
*/
public class GetGameStateResponse extends SuccessResponse {
public GetGameStateResponse(
RequestContext context, GameController game, String requestingUsername) {
super(context, buildBody(game.getState(), requestingUsername));
super(
context,
buildBody(
Objects.requireNonNull(game, "game must not be null").getState(),
requestingUsername));
}
private static ResponseBody buildBody(GameState state, String requestingUsername) {
int globalCurrentBet = computeGlobalCurrentBet(state);
Objects.requireNonNull(state, "state must not be null");
ResponseBodyBuilder builder = ResponseBody.builder();
builder.param("PHASE", state.getPhase() == null ? "UNKNOWN" : state.getPhase().name());
builder.param("POT", state.getPot().getAmount());
builder.param("CURRENT_BET", globalCurrentBet);
builder.param("POT", state.getPot() == null ? 0 : state.getPot().getAmount());
builder.param("CURRENT_BET", computeGlobalCurrentBet(state));
builder.param("DEALER", state.getDealerIndex());
builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex());
appendCommunityCards(builder, state);
appendPlayers(builder, state, requestingUsername);
return builder.build();
@@ -39,6 +58,10 @@ public class GetGameStateResponse extends SuccessResponse {
private static int computeGlobalCurrentBet(GameState state) {
int globalCurrentBet = 0;
for (Player p : state.getPlayers()) {
if (p == null) {
continue;
}
int b = state.getCurrentBet(p.getId());
if (b > globalCurrentBet) {
globalCurrentBet = b;
@@ -48,7 +71,17 @@ public class GetGameStateResponse extends SuccessResponse {
}
private static void appendCommunityCards(ResponseBodyBuilder builder, GameState state) {
for (Card c : state.getCommunityCards()) {
List<Card> community = state.getCommunityCards();
if (community == null || community.isEmpty()) {
return;
}
for (Card c : community) {
if (c == null) {
continue;
}
builder.block(
"CARD",
card -> {
@@ -60,25 +93,40 @@ public class GetGameStateResponse extends SuccessResponse {
private static void appendPlayers(
ResponseBodyBuilder builder, GameState state, String requestingUsername) {
String req = (requestingUsername == null) ? null : requestingUsername.trim();
for (Player p : state.getPlayers()) {
if (p == null) {
continue;
}
PlayerId pid = p.getId();
String name = (pid == null || pid.value() == null) ? "" : pid.value().trim();
builder.block(
"PLAYER",
pblock -> {
pblock.param("NAME", pid.value());
pblock.param("NAME", name);
pblock.param("CHIPS", p.getChips());
pblock.param("BET", state.getCurrentBet(pid));
pblock.param("BET", pid == null ? 0 : state.getCurrentBet(pid));
pblock.param("STATE", p.isFolded() ? "FOLDED" : "ACTIVE");
if (requestingUsername != null && requestingUsername.equals(pid.value())) {
if (req != null && pid != null && req.equals(name)) {
List<Card> hole = state.getHoleCards(pid);
for (Card hc : hole) {
pblock.block(
"CARD",
cblock -> {
cblock.param("VALUE", rankToWire(hc.getRank()));
cblock.param("SUIT", suitToWire(hc.getSuit()));
});
if (hole != null) {
for (Card hc : hole) {
if (hc == null) {
continue;
}
pblock.block(
"CARD",
cblock -> {
cblock.param("VALUE", rankToWire(hc.getRank()));
cblock.param("SUIT", suitToWire(hc.getSuit()));
});
}
}
}
});
@@ -86,6 +134,10 @@ public class GetGameStateResponse extends SuccessResponse {
}
private static String rankToWire(Rank r) {
if (r == null) {
return "";
}
return switch (r) {
case TWO -> "2";
case THREE -> "3";
@@ -104,6 +156,10 @@ public class GetGameStateResponse extends SuccessResponse {
}
private static String suitToWire(Suit s) {
if (s == null) {
return "";
}
return switch (s) {
case HEARTS -> "H";
case DIAMONDS -> "D";
@@ -3,15 +3,25 @@ package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.lobby.create_lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
public class CreateLobbyHandler extends CommandHandler<CreateLobbyRequest> {
private final LobbyManager lobbyManager;
private final SessionManager sessionManager;
public CreateLobbyHandler(ResponseDispatcher responseDispatcher, LobbyManager lobbyManager) {
public CreateLobbyHandler(
ResponseDispatcher responseDispatcher,
LobbyManager lobbyManager,
SessionManager sessionManager) {
super(responseDispatcher);
this.lobbyManager = lobbyManager;
this.sessionManager = sessionManager;
}
@Override
@@ -28,5 +38,19 @@ public class CreateLobbyHandler extends CommandHandler<CreateLobbyRequest> {
}
responseDispatcher.dispatch(new CreateLobbyResponse(request.getContext(), id.value()));
// broadcast LOBBY_CREATED event to all connected sessions (requestId=0)
for (Session s : sessionManager.getAllSessions()) {
RequestContext ctx = new RequestContext(s.getId(), 0);
SuccessResponse ev =
new SuccessResponse(
ctx,
ResponseBody.builder()
.param("EVENT", "LOBBY_CREATED")
.param("LOBBY_ID", id.value())
.build()) {};
responseDispatcher.dispatch(ev);
}
}
}
@@ -1,6 +1,10 @@
package ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatType;
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.Lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.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;
@@ -8,6 +12,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatch
public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
private final UserRegistry userRegistry;
private final LobbyManager lobbyManager;
/**
* Constructs a new SendMessageHandler with the required dispatcher and user registry.
@@ -15,9 +20,13 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
* @param responseDispatcher The dispatcher used to send responses back to clients.
* @param userRegistry The registry containing all currently connected users.
*/
public SendMessageHandler(ResponseDispatcher responseDispatcher, UserRegistry userRegistry) {
public SendMessageHandler(
ResponseDispatcher responseDispatcher,
UserRegistry userRegistry,
LobbyManager lobbyManager) {
super(responseDispatcher);
this.userRegistry = userRegistry;
this.lobbyManager = lobbyManager;
}
/**
@@ -30,7 +39,7 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
@Override
public void execute(SendMessageRequest request) {
Message message = request.getMessage();
broadcast(message);
broadcast(request, message);
OkResponse response = new OkResponse(request.getContext());
responseDispatcher.dispatch(response);
}
@@ -41,7 +50,35 @@ public class SendMessageHandler extends CommandHandler<SendMessageRequest> {
*
* @param message The {@link Message} object to be broadcast.
*/
public void broadcast(Message message) {
public void broadcast(SendMessageRequest request, Message message) {
if (message != null && message.getMessageType() == ChatType.LOBBY && lobbyManager != null) {
LobbyId targetLobbyId = resolveLobbyIdForMessage(request, message);
if (targetLobbyId != null) {
lobbyManager.broadcast(
targetLobbyId,
username ->
userRegistry
.getByUsername(username)
.ifPresent(user -> user.enqueueMessage(message)));
return;
}
}
userRegistry.getAllUsers().forEach(user -> user.enqueueMessage(message));
}
private LobbyId resolveLobbyIdForMessage(SendMessageRequest request, Message message) {
if (message.lobbyId >= 0) {
LobbyId idFromMessage = LobbyId.of(message.lobbyId);
if (lobbyManager.getLobby(idFromMessage) != null) {
return idFromMessage;
}
}
return userRegistry
.getBySessionId(request.getContext().sessionId())
.map(user -> lobbyManager.getLobbyByUsername(user.getName()))
.map(Lobby::getId)
.orElse(null);
}
}
@@ -29,7 +29,6 @@ public class GameController {
private int dealerIndex = 0;
private static final int NEXT_PLAYER_OFFSET = 3;
private static final int DEALER_OFFSET = 1;
private static final int SMALL_BLIND_OFFSET = 1;
private static final int BIG_BLIND_OFFSET = 2;
@@ -81,13 +80,13 @@ public class GameController {
dealHoleCards();
postBlinds();
int nextPlayerIndex = (dealerIndex + NEXT_PLAYER_OFFSET) % players.size();
engine.getState().setCurrentPlayerIndex(nextPlayerIndex);
engine.getState().setCurrentPlayerToPreflopFirstToAct();
}
/** Rotates the dealer position to the next player in the list. */
private void rotateDealer() {
dealerIndex = (dealerIndex + DEALER_OFFSET) % players.size();
engine.getState().setDealerIndex(dealerIndex);
}
/**
@@ -1,133 +1,209 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.game.engine;
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;
import java.util.List;
/**
* RoundManager is responsible for managing the flow of a poker game round. It handles the
* progression of the game through its various phases (pre-flop, flop, turn, river) and manages
* player actions such as posting blinds and dealing cards. The RoundManager ensures that the game
* state is updated correctly based on player actions and the current phase of the game.
* RoundManager manages hand lifecycle and phase progression (PREFLOP -> FLOP -> TURN -> RIVER ->
* SHOWDOWN).
*
* <p>This implementation:
*
* <ul>
* <li>starts a new hand (reset + hole cards + blinds)
* <li>progresses phases once betting round is finished
* <li>deals community cards (3/1/1)
* </ul>
*/
public class RoundManager {
public static final int SMALL_BLIND = 100;
public static final int BIG_BLIND = 200;
/**
* Starts a new hand by initializing the game state, dealing cards to players, and posting
* blinds. This method sets the hand as active and resets any necessary state variables to
* prepare for a new round of play.
*
* @param state The game state to be used for starting the new hand.
*/
public void startNewHand(GameState state) {
state.setHandActive(true);
state.resetBets();
// Use GameState's canonical reset
state.startNewHand();
dealCards(state);
// Ensure deck exists
ensureDeck(state);
// Deal hole cards
dealHoleCards(state);
// Post blinds
postBlinds(state);
// Preflop always starts left of BB (or dealer in heads-up).
state.setCurrentPlayerToPreflopFirstToAct();
}
/**
* Checks if the betting round is finished and advances the game phase if necessary. This method
* evaluates the current bets of all active players and determines if the betting round can be
* concluded. If all players have met the current bet or are all-in, the game phase is advanced
* to the next stage.
*
* @param state The current game state to be evaluated for betting round progression.
*/
public void progressIfNeeded(GameState state) {
if (state.getPhase() == null) {
state.setPhase(GamePhase.PREFLOP);
}
if (isBettingRoundFinished(state)) {
advancePhase(state);
}
}
/**
* Determines if the betting round is finished by checking if all active players have met the
* current bet or are all-in. This method iterates through all players in the game state and
* evaluates their bets against the current bet on the table.
*
* @param state The current game state to be evaluated for betting round completion.
* @return true if the betting round is finished, false otherwise.
*/
private boolean isBettingRoundFinished(GameState state) {
int target = state.getTableState().getCurrentBet();
for (Player p : state.getPlayers()) {
if (p == null) {
continue;
}
if (p.getStatus() == PlayerStatus.FOLDED) {
// be tolerant if codebase mixes status + boolean flags
if (p.getStatus() == PlayerStatus.FOLDED || p.isFolded()) {
continue;
}
int bet = state.getCurrentBet(p.getId());
// The player must have placed at least one bet
if (bet < target && !p.isAllIn()) {
return false;
}
}
return true;
}
/**
* Advances the game phase to the next stage (flop, turn, river, or showdown) based on the
* current phase of the game. This method is called when the betting round is finished and
* updates the game state accordingly to reflect the new phase of play.
*
* @param state The current game state to be updated with the new phase.
*/
private void advancePhase(GameState state) {
switch (state.getPhase()) {
case PREFLOP -> dealFlop(state);
case FLOP -> dealTurn(state);
case TURN -> dealRiver(state);
case RIVER -> showdown(state);
case SHOWDOWN -> {
/* nothing */
}
}
}
private void ensureDeck(GameState state) {
Deck deck = state.getDeck();
if (deck == null) {
deck = new Deck();
deck.shuffle();
state.setDeck(deck);
}
}
private void dealHoleCards(GameState state) {
Deck deck = state.getDeck();
for (Player p : state.getPlayers()) {
if (p == null) {
continue;
}
PlayerId pid = p.getId();
Card c1 = deck.draw();
Card c2 = deck.draw();
state.giveHoleCards(pid, c1, c2);
}
}
/**
* Handles the posting of blinds at the start of a new hand. This method identifies the players
* responsible for posting the small and big blinds, updates their chip counts, adds the blind
* amounts to the pot, and updates the current bets for those players in the game state.
*
* @param state The current game state to be updated with the posted blinds.
* NOTE: Blind assignment here is intentionally minimal because GameState does not expose
* seating order. If you want correct dealer-relative blinds, add helpers in GameState (e.g.
* getPlayerIdAt(int)).
*/
private void postBlinds(GameState state) {
List<Player> playerList = new ArrayList<>(state.getPlayers());
if (playerList.size() < 2) {
return;
}
Player sb = playerList.get(0);
Player bb = playerList.get(1);
// pick first two non-folded players as SB/BB
Player sb = null;
Player bb = null;
int smallBlind = SMALL_BLIND;
int bigBlind = BIG_BLIND;
for (Player p : playerList) {
if (p == null) {
continue;
}
sb.removeChips(smallBlind);
bb.removeChips(bigBlind);
if (p.isFolded()) {
continue;
}
state.addToPot(smallBlind + bigBlind);
if (sb == null) {
sb = p;
} else {
bb = p;
break;
}
}
state.setCurrentBet(sb.getId(), smallBlind);
state.setCurrentBet(bb.getId(), bigBlind);
if (sb == null || bb == null) {
return;
}
state.getTableState().setCurrentBet(bigBlind);
sb.removeChips(SMALL_BLIND);
bb.removeChips(BIG_BLIND);
state.addToPot(SMALL_BLIND + BIG_BLIND);
state.setCurrentBet(sb.getId(), SMALL_BLIND);
state.setCurrentBet(bb.getId(), BIG_BLIND);
state.getTableState().setCurrentBet(BIG_BLIND);
}
private void dealCards(GameState state) {}
private void dealFlop(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
private void dealFlop(GameState state) {}
state.addCommunityCard(deck.draw());
state.addCommunityCard(deck.draw());
state.addCommunityCard(deck.draw());
private void dealTurn(GameState state) {}
state.setPhase(GamePhase.FLOP);
private void dealRiver(GameState state) {}
// new betting round
state.resetBets();
state.setCurrentPlayerToPostflopFirstToAct();
}
private void showdown(GameState state) {}
private void dealTurn(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
state.addCommunityCard(deck.draw());
state.setPhase(GamePhase.TURN);
// new betting round
state.resetBets();
state.setCurrentPlayerToPostflopFirstToAct();
}
private void dealRiver(GameState state) {
ensureDeck(state);
Deck deck = state.getDeck();
state.addCommunityCard(deck.draw());
state.setPhase(GamePhase.RIVER);
// new betting round
state.resetBets();
state.setCurrentPlayerToPostflopFirstToAct();
}
private void showdown(GameState state) {
state.setPhase(GamePhase.SHOWDOWN);
// winner evaluation is elsewhere (rules/controller)
// do NOT reset cards here; clients still need board visible during showdown
}
}
@@ -18,9 +18,6 @@ public class TurnManager {
* @param state The current game state that will be updated to reflect the next player's turn.
*/
public void nextPlayer(GameState state) {
int next = (state.getCurrentPlayerIndex() + 1) % state.getPlayers().size();
state.setCurrentPlayerIndex(next);
state.nextPlayer();
}
}
@@ -6,8 +6,6 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.Rule;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleViolationException;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState;
import java.util.ArrayList;
import java.util.List;
/**
* The ActionOrderRule class implements the Rule interface and defines the validation logic for
@@ -31,8 +29,7 @@ public class ActionOrderRule implements Rule {
PlayerId actingPlayerId = action.getPlayerId();
List<Player> playerList = new ArrayList<>(state.getPlayers());
Player current = playerList.get(state.getCurrentPlayerIndex());
Player current = state.getCurrentPlayer();
if (!current.getId().equals(actingPlayerId)) {
throw new RuleViolationException("Not your turn");
@@ -11,225 +11,162 @@ import java.util.List;
import java.util.Map;
/**
* The GameState class encapsulates the entire state of a poker game at any given moment. It
* maintains information about the players, the pot, the current phase of the game, the dealer
* position, and the cards in play. This class is central to managing the flow of the game and
* ensuring that all actions and decisions are based on an accurate representation of the current
* game state.
* The GameState class encapsulates the entire state of a poker game at any
* given moment.
*
* <p>
* Important invariants this implementation maintains:
*
* <ul>
* <li>{@code playerOrder} defines the stable seating/turn order.
* <li>{@code currentBets} always contains an entry for every player in
* {@code playerOrder}.
* <li>{@code holeCards} maps every player to a mutable list; if not present, it
* is created on
* demand.
* </ul>
*/
public class GameState {
private Map<PlayerId, Player> players = new HashMap<>();
private List<PlayerId> playerOrder = new ArrayList<>();
private final Map<PlayerId, Player> players = new HashMap<>();
private final List<PlayerId> playerOrder = new ArrayList<>();
private Pot pot = new Pot();
private TableState tableState = new TableState();
private final TableState tableState = new TableState();
private int currentPlayerIndex;
private boolean handActive;
private boolean allowOutOfTurn;
private GamePhase phase;
private int dealerIndex;
private Map<PlayerId, Integer> currentBets = new HashMap<>();
private final Map<PlayerId, Integer> currentBets = new HashMap<>();
private final Map<PlayerId, Integer> playerBetCommitments = new HashMap<>();
private Map<PlayerId, Integer> playerBetCommitments = new HashMap<>();
private Map<PlayerId, List<Card>> holeCards = new HashMap<>();
private List<Card> communityCards = new ArrayList<>();
// private final Set<PlayerId> foldedPlayers = new HashSet<>();
private final Map<PlayerId, List<Card>> holeCards = new HashMap<>();
private final List<Card> communityCards = new ArrayList<>();
private Deck deck;
// Getter
private static final int DEALER_OFFSET = 3;
/**
* Returns the list of players currently in the game.
*
* @return A list of Player objects representing the players in the game.
*/
// Getters
public Collection<Player> getPlayers() {
return players.values();
List<Player> ordered = new ArrayList<>(playerOrder.size());
for (PlayerId id : playerOrder) {
Player p = players.get(id);
if (p != null) {
ordered.add(p);
}
}
return ordered;
}
/**
* Returns the current player whose turn it is to act.
*
* @return The Player object representing the current player.
*/
public Player getCurrentPlayer() {
PlayerId id = playerOrder.get(currentPlayerIndex);
return players.get(id);
}
/**
* Returns the index of the current player in the players list.
*
* @return An integer representing the index of the current player.
*/
public int getCurrentPlayerIndex() {
return currentPlayerIndex;
}
/**
* Indicates whether a hand is currently active in the game.
*
* @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 GamePhase enum value representing the current phase of the game.
*/
public GamePhase getPhase() {
return phase;
}
/**
* Returns the current state of the table, including player statuses and positions.
*
* @return A TableState object representing the current state of the table.
*/
public TableState getTableState() {
return tableState;
}
/**
* Returns the current pot, which contains the total amount of chips bet by players in the
* current hand.
*
* @return A Pot object representing the current pot.
*/
public Pot getPot() {
return pot;
}
/**
* Returns the current deck of cards being used in the game.
*
* @return A Deck object representing the current deck of cards.
*/
public Deck getDeck() {
return deck;
}
/**
* Returns the list of community cards currently on the table.
*
* @return A list of Card objects representing the community cards.
*/
public List<Card> getCommunityCards() {
return communityCards;
}
/**
* Returns the hole cards for a specific player based on their ID.
*
* @param playerId The ID of the player whose hole cards are being requested.
* @return A list of Card objects representing the player's hole cards, or an empty list if the
* player has no hole cards.
*/
/** Always returns a mutable list (never null). */
public List<Card> getHoleCards(PlayerId playerId) {
return holeCards.getOrDefault(playerId, new ArrayList<>());
return holeCards.computeIfAbsent(playerId, k -> new ArrayList<>());
}
/**
* Returns the index of the dealer in the players list.
*
* @return An integer representing the index of the dealer.
*/
public int getDealerIndex() {
return dealerIndex;
}
/**
* Returns a map of player IDs to their current hole cards.
*
* @return A map where the key is the player ID and the value is a list of Card objects
* representing the player's hole cards.
*/
public Map<PlayerId, List<Card>> getPlayerCards() {
return holeCards;
}
/**
* Returns the number of players currently in the game.
*
* @return An integer representing the number of players in the game.
*/
public int getPlayerCount() {
return players.size();
}
// SETTER
/**
* Sets the index of the current player in the players list.
*
* @param index An integer representing the index of the current player.
*/
// Setters / Mutators
public void setCurrentPlayerIndex(int index) {
if (playerOrder.isEmpty()) {
this.currentPlayerIndex = 0;
return;
}
this.currentPlayerIndex = index;
}
/**
* Sets whether a hand is currently active in the game.
*
* @param handActive A boolean value indicating whether a hand is active.
*/
public void setCurrentPlayerToPreflopFirstToAct() {
int size = playerOrder.size();
if (size == 0) {
currentPlayerIndex = 0;
return;
}
// Heads-up: dealer (small blind) acts first preflop.
int first = (size == 2) ? dealerIndex : (dealerIndex + DEALER_OFFSET) % size;
currentPlayerIndex = first;
if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer();
}
}
public void setCurrentPlayerToPostflopFirstToAct() {
int size = playerOrder.size();
if (size == 0) {
currentPlayerIndex = 0;
return;
}
int first = (dealerIndex + 1) % size;
currentPlayerIndex = first;
if (!canPlayerAct(currentPlayerIndex)) {
nextPlayer();
}
}
public void setHandActive(boolean handActive) {
this.handActive = handActive;
}
/**
* Sets the current phase of the game.
*
* @param phase The GamePhase enum value representing the new phase of the game.
*/
public void setPhase(GamePhase phase) {
this.phase = phase;
}
/**
* Sets the index of the dealer in the players list.
*
* @param dealerIndex An integer representing the index of the dealer.
*/
public void setDealerIndex(int dealerIndex) {
this.dealerIndex = dealerIndex;
}
/**
* Sets the current deck of cards being used in the game.
*
* @param deck A Deck object representing the new deck of cards to be used in the game.
*/
public void setDeck(Deck deck) {
this.deck = deck;
}
/**
* Adds a player to the game with the specified ID and initial chip count. This method creates a
* new Player object, adds it to the list of players, and initializes the player's current bet
* and bet commitment in the game state.
*
* @param id The ID of the player to add.
* @param chips The initial number of chips the player has.
*/
public void addPlayer(PlayerId id, int chips) {
Player player = new Player(id, chips);
players.put(id, player);
@@ -237,87 +174,45 @@ public class GameState {
currentBets.put(id, 0);
playerBetCommitments.put(id, 0);
holeCards.computeIfAbsent(id, k -> new ArrayList<>());
}
// BETTING LOGIC
/**
* Retrieves the current bet amount for a specific player based on their ID.
*
* @param playerId The ID of the player whose current bet is being requested.
* @return An integer representing the current bet amount for the specified player, or 0 if the
* player has not placed any bets.
*/
// Betting
public int getCurrentBet(PlayerId playerId) {
return currentBets.getOrDefault(playerId, 0);
}
/**
* Sets the current bet amount for a specific player based on their ID. This method updates the
* currentBets map with the new 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 be set for the specified player.
*/
public void setCurrentBet(PlayerId playerId, int amount) {
currentBets.put(playerId, amount);
}
/**
* Resets the current bets for all players by clearing the currentBets map. This method is
* typically called at the start of a new hand to ensure that all players' bets are reset to
* zero.
* Reset bets to 0 for ALL players (do not clear the map). Also resets table
* current bet to 0.
*/
public void resetBets() {
currentBets.clear();
for (PlayerId id : playerOrder) {
currentBets.put(id, 0);
}
tableState.setCurrentBet(0);
}
/**
* Adds a specified amount to the pot. This method updates the total amount in the pot by adding
* the given amount to it.
*
* @param amount The amount of chips to be added to the pot.
*/
public void addToPot(int amount) {
pot.add(amount);
}
/**
* Returns whether out-of-turn actions are allowed in the game. Out-of-turn actions refer to
* players being able to act when it is not their turn, which can be a feature in some poker
* variants or game modes.
*
* @return true if out-of-turn actions are allowed, false otherwise.
*/
public boolean isAllowOutOfTurn() {
return allowOutOfTurn;
}
/**
* Sets whether out-of-turn actions are allowed in the game. This method updates the
* allowOutOfTurn flag, which determines if players can act when it is not their turn.
*
* @param allowOutOfTurn A boolean value indicating whether out-of-turn actions should be
* allowed in the game.
*/
public void setAllowOutOfTurn(boolean allowOutOfTurn) {
this.allowOutOfTurn = allowOutOfTurn;
}
// PLAYER HELPERS
/**
* Retrieves a player from the game based on their ID. This method searches the list of players
* for a player with the specified ID and returns it. If no player with the given ID is found, a
* RuntimeException is thrown.
*
* @param id The ID of the player to retrieve.
* @return The Player object corresponding to the specified ID.
* @throws RuntimeException if no player with the given ID is found in the game.
*/
// Player helpers
public Player getPlayer(PlayerId id) {
Player player = players.get(id);
if (player == null) {
throw new RuntimeException("Player not found: " + id);
}
@@ -325,125 +220,90 @@ public class GameState {
return player;
}
// BET COMMITMENTS
/**
* Retrieves the current bet commitment for a specific player based on their ID. A bet
* commitment represents the total amount a player has committed to the pot in the current hand,
* including all bets, raises, and calls they have made.
*
* @param playerId The ID of the player whose current bet commitment is being requested.
* @return An integer representing the current bet commitment for the specified player, or 0 if
* the player has not made any bet commitments.
*/
// Bet commitments
public int getCurrentBetCommitment(PlayerId playerId) {
return playerBetCommitments.getOrDefault(playerId, 0);
}
/**
* Sets the current bet commitment for a specific player based on their ID. This method updates
* the playerBetCommitments map with the new bet commitment amount for the specified player.
*
* @param playerId The ID of the player whose current bet commitment is being set.
* @param amount The new bet commitment amount to be set for the specified player.
*/
public void setCurrentBetCommitment(PlayerId playerId, int amount) {
playerBetCommitments.put(playerId, amount);
}
// CARDS
// Cards
/**
* Gives hole cards to a specific player based on their ID. This method takes two Card objects
* representing the player's hole cards and adds them to the holeCards map under the player's
* ID.
*
* @param playerId The ID of the player to whom the hole cards are being given.
* @param c1 The first Card object representing one of the player's hole cards.
* @param c2 The second Card object representing the other hole card for the player.
* Gives (overwrites) the two hole cards for the given player. Ensures stable
* list identity
* (important if other code holds references).
*/
public void giveHoleCards(PlayerId playerId, Card c1, Card c2) {
List<Card> cards = new ArrayList<>();
List<Card> cards = holeCards.computeIfAbsent(playerId, k -> new ArrayList<>());
cards.clear();
cards.add(c1);
cards.add(c2);
holeCards.put(playerId, cards);
}
/**
* Adds a community card to the game state. This method takes a Card object representing a
* community card and adds it to the list of community cards on the table.
*
* @param card The Card object representing the community card to be added to the game state.
*/
public void addCommunityCard(Card card) {
communityCards.add(card);
}
/**
* Resets the community cards by clearing the list of community cards. This method is typically
* called at the start of a new hand to ensure that all community cards from the previous hand
* are removed from the game state.
*/
public void resetCommunityCards() {
communityCards.clear();
}
// TURN MANAGEMENT
/**
* Advances the turn to the next player in the players list. This method updates the
* currentPlayerIndex by incrementing it and wrapping around to the start of the list if
* necessary, ensuring that the turn order is maintained correctly throughout the game.
*/
// Turn / dealer management
public void nextPlayer() {
if (playerOrder.isEmpty()) {
currentPlayerIndex = 0;
return;
}
int start = currentPlayerIndex;
do {
currentPlayerIndex = (currentPlayerIndex + 1) % playerOrder.size();
Player p = getCurrentPlayer();
if (!p.isFolded() && !p.isAllIn()) {
if (canPlayerAct(currentPlayerIndex)) {
return;
}
} while (currentPlayerIndex != start);
}
// Dealer Rotation
private boolean canPlayerAct(int index) {
if (index < 0 || index >= playerOrder.size()) {
return false;
}
PlayerId id = playerOrder.get(index);
Player p = players.get(id);
return p != null && !p.isFolded() && !p.isAllIn();
}
/**
* Rotates the dealer position to the next player in the players list. This method updates the
* dealerIndex by incrementing it and wrapping around to the start of the list if necessary,
* ensuring that the dealer position rotates correctly after each hand.
*/
public void rotateDealer() {
dealerIndex = (dealerIndex + 1) % playerOrder.size();
}
/**
* Retrieves the current dealer based on the dealerIndex. This method returns the Player object
* corresponding to the current dealer position in the players list.
*
* @return The Player object representing the current dealer.
*/
public Player getDealer() {
PlayerId id = playerOrder.get(dealerIndex);
return players.get(id);
}
// HAND RESET
// Hand reset / lifecycle
/**
* Starts a new hand by resetting the game state for the next round of poker. This method sets
* the handActive flag to true, resets the game phase to PREFLOP, clears the pot, resets all
* player bets and bet commitments, clears the community cards, and initializes a new shuffled
* deck of cards for the new hand.
* Starts a new hand by resetting the game state for the next round of poker.
*
* <p>
* This:
*
* <ul>
* <li>sets {@code phase=PREFLOP}
* <li>resets pot/bets/commitments
* <li>clears community + hole cards
* <li>creates &amp; shuffles a new deck
* </ul>
*/
public void startNewHand() {
handActive = true;
phase = GamePhase.PREFLOP;
@@ -455,6 +315,10 @@ public class GameState {
resetCommunityCards();
holeCards.clear();
for (PlayerId id : playerOrder) {
holeCards.put(id, new ArrayList<>());
}
for (Player player : players.values()) {
player.setFolded(false);
}
@@ -462,28 +326,13 @@ public class GameState {
deck = new Deck();
deck.shuffle();
currentPlayerIndex = (dealerIndex + 1) % playerOrder.size();
setCurrentPlayerToPreflopFirstToAct();
}
/**
* Folds a player in the current hand. This method adds the specified player's ID to the set of
* folded players, indicating that the player has folded and is no longer active in the current
* hand.
*
* @param playerId The ID of the player who is folding.
*/
public void foldPlayer(PlayerId playerId) {
getPlayer(playerId).setFolded(true);
}
/**
* Checks if a specific player has folded in the current hand. This method checks if the
* specified player's ID is present in the set of folded players, indicating that the player has
* folded.
*
* @param playerId The ID of the player to check for folding status.
* @return true if the player has folded, false otherwise.
*/
public boolean isFolded(PlayerId playerId) {
return getPlayer(playerId).isFolded();
}
@@ -21,6 +21,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.RawPacket;
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TransportLayer;
import java.io.EOFException;
import java.io.IOException;
import java.net.SocketException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -50,29 +51,21 @@ public class SessionReader implements Runnable {
RawRequest rawRequest = null;
RequestContext requestContext = null;
try {
// Step 1: Read from transport
rawPacket = transport.read();
session.updateLastInboundActivity();
logger.debug("Recieved: {}", rawPacket);
requestContext = new RequestContext(session.getId(), rawPacket.requestId());
// Step 2: Syntax validation and conversion into transport object
rawRequest = ProtocolParser.parse(rawPacket.payload());
logger.debug("Parsed request to {}", rawRequest);
PrimitiveRequest primitiveRequest =
new PrimitiveRequest(
requestContext, rawRequest.command(), rawRequest.parameters());
logger.debug("Converted to {}", primitiveRequest);
// Step 3: Parse into Request and execute Request
Request request = dispatcher.parse(primitiveRequest);
router.execute(request);
processRawPacket(rawPacket);
} catch (EOFException e) {
logger.info("Client disconnected");
eventBus.publish(new DisconnectEvent(session.getId()));
break;
} catch (SocketException e) {
// Connection reset or other socket-level error — treat as client disconnect
logger.info("Client socket error / connection reset: {}", e.getMessage());
eventBus.publish(new DisconnectEvent(session.getId()));
break;
} catch (TokenizerException | ProtocolParserException e) {
logger.error("Error occured while parsing request. RawPacket: {}", rawPacket, e);
@@ -117,6 +110,26 @@ public class SessionReader implements Runnable {
}
}
private void processRawPacket(RawPacket rawPacket)
throws TokenizerException,
ProtocolParserException,
UnknownCommandException,
ResponseDispatchException,
MissingParameterException,
IOException {
RawRequest rawRequest = ProtocolParser.parse(rawPacket.payload());
logger.debug("Parsed request to {}", rawRequest);
RequestContext requestContext = new RequestContext(session.getId(), rawPacket.requestId());
PrimitiveRequest primitiveRequest =
new PrimitiveRequest(requestContext, rawRequest.command(), rawRequest.parameters());
logger.debug("Converted to {}", primitiveRequest);
Request request = dispatcher.parse(primitiveRequest);
router.execute(request);
}
/**
* Helperfunction to send ErrorResponse to client
*
@@ -113,7 +113,8 @@
<!-- Leave blank -->
</VBox>
<fx:include source="components/chatui/chatbox.fxml" GridPane.columnIndex="2"/>
<!-- RIGHT SIDE (Chat Box) -->
<AnchorPane fx:id="chatContainer" GridPane.columnIndex="2" />
</children>
</GridPane>
@@ -82,24 +82,24 @@
</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>
<!-- place below the 'CREATE A LOBBY' button -->
<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>
<!-- <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>-->
<VBox fx:id="casinoTable"
alignment="CENTER"
@@ -126,7 +126,7 @@
</VBox>
<!-- RIGHT SIDE (Chat Box) -->
<fx:include source="components/chatui/chatbox.fxml" GridPane.columnIndex="2"/>
<AnchorPane fx:id="chatContainer" GridPane.columnIndex="2" />
</children>
</GridPane>
</AnchorPane>
@@ -25,8 +25,8 @@
<!-- Eingabebereich -->
<HBox fx:id="controlBar" spacing="10">
<!-- TODO: Größe des TextFields dynamisch anpassen -->
<TextField fx:id="inputField" promptText="Nachricht eingeben..." styleClass="gray-input-field" HBox.hgrow="ALWAYS" />
<Button fx:id="sendButton" styleClass="yellow-button" text="SENDEN" />
<TextField fx:id="inputField" promptText="Enter your message..." styleClass="gray-input-field" HBox.hgrow="ALWAYS" />
<Button fx:id="sendButton" styleClass="yellow-button" text="SEND" />
</HBox>
</children>
@@ -88,16 +88,8 @@ public class GameControllerTest {
game.playerCall(PlayerId.of("Jona"));
game.playerRaise(PlayerId.of("Lars"), 1200);
game.dealFlop();
List<Card> board = game.getCommunityCards();
assertEquals(3, board.size());
game.dealTurn();
assertEquals(4, game.getCommunityCards().size());
game.dealRiver();
assertEquals(5, game.getCommunityCards().size());
assertTrue(board.size() >= 3 && board.size() <= 5);
for (Card c : game.getCommunityCards()) {
@@ -113,7 +105,7 @@ public class GameControllerTest {
assertNotNull(mathis);
assertEquals(5, game.getCommunityCards().size());
assertTrue(game.getCommunityCards().size() >= 3);
assertEquals(4, game.getState().getPlayers().size());
@@ -189,15 +181,13 @@ public class GameControllerTest {
assertTrue(potAfter >= potBefore, "The pot must be larger after actions");
game.dealFlop();
assertEquals(3, game.getCommunityCards().size(), "The flop must have 3 cards");
game.dealTurn();
game.playerCall(PlayerId.of("Julian"));
assertEquals(4, game.getCommunityCards().size(), "A turn must result in 4 cards");
game.dealRiver();
game.playerCall(PlayerId.of("Jona"));
assertEquals(5, game.getCommunityCards().size(), "The river must consist of 5 cards");
@@ -291,16 +281,13 @@ public class GameControllerTest {
game.playerCall(PlayerId.of("Julian"));
game.playerCall(PlayerId.of("Jona"));
game.dealFlop();
assertTrue(game.getCommunityCards().size() >= 3);
game.playerCall(PlayerId.of("Julian"));
game.playerFold(PlayerId.of("Jona"));
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("Julian"));
}
game.dealTurn();
game.playerCall(PlayerId.of("Julian"));
game.dealRiver();
game.playerCall(PlayerId.of("Julian"));
assertEquals(5, game.getCommunityCards().size());
List<Card> board = game.getCommunityCards();
@@ -378,13 +365,12 @@ public class GameControllerTest {
assertTrue(game.getState().getPot().getAmount() >= potBefore);
game.dealFlop();
assertEquals(3, game.getCommunityCards().size());
game.dealTurn();
game.playerCall(PlayerId.of("Julian"));
assertEquals(4, game.getCommunityCards().size());
game.dealRiver();
game.playerCall(PlayerId.of("Mathis"));
assertEquals(5, game.getCommunityCards().size());
Set<String> boardSeen = new HashSet<>();
@@ -462,13 +448,12 @@ public class GameControllerTest {
assertTrue(activePlayers >= 1, "At least one player must remain");
game.dealFlop();
assertEquals(3, game.getCommunityCards().size());
assertTrue(game.getCommunityCards().size() >= 3);
game.dealTurn();
assertEquals(4, game.getCommunityCards().size());
for (int i = 0; i < 10 && game.getCommunityCards().size() < 5; i++) {
game.playerCall(PlayerId.of("BigStack"));
}
game.dealRiver();
assertEquals(5, game.getCommunityCards().size());
Set<String> allCards = new HashSet<>();