Merge branch 'main' into feat/manual-improvement

This commit is contained in:
Mathis Ginkel
2026-05-17 14:43:16 +02:00
10 changed files with 334 additions and 57 deletions
@@ -1,7 +1,9 @@
package ch.unibas.dmi.dbis.cs108.casono.client.game;
import ch.unibas.dmi.dbis.cs108.casono.client.network.GameClient;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -11,6 +13,9 @@ public class GameService {
private final GameClient client;
private GameState state;
private final Queue<Runnable> actionQueue = new LinkedList<>();
private volatile boolean actionInProgress = false;
private static final long ACTION_TIMEOUT_MS = 5000;
/**
* Constructs a GameService with the specified GameClient.
@@ -112,12 +117,12 @@ public class GameService {
/** Retrieves the current phase of the game. */
public void call() {
client.sendCall();
queueAction(() -> client.sendCall());
}
/** Retrieves the current phase of the game. */
public void fold() {
client.sendFold();
queueAction(() -> client.sendFold());
}
/**
@@ -126,7 +131,7 @@ public class GameService {
* @param amount The amount to bet. Must be a positive integer.
*/
public void bet(int amount) {
client.sendBet(amount);
queueAction(() -> client.sendBet(amount));
}
/**
@@ -135,7 +140,63 @@ public class GameService {
* @param amount The amount to raise. Must be a positive integer.
*/
public void raise(int amount) {
client.sendRaise(amount);
queueAction(() -> client.sendRaise(amount));
}
/**
* Queue an action to be executed sequentially. Only one action is processed at a time to
* prevent concurrent operations from overwhelming the server or UI.
*
* @param action The action to queue and execute.
*/
private void queueAction(Runnable action) {
synchronized (actionQueue) {
actionQueue.offer(action);
if (!actionInProgress) {
processNextAction();
}
}
}
/**
* Process the next queued action if one is available. This method ensures that only one action
* is in progress at any given time.
*/
private void processNextAction() {
synchronized (actionQueue) {
if (actionQueue.isEmpty() || actionInProgress) {
return;
}
actionInProgress = true;
Runnable action = actionQueue.poll();
if (action != null) {
try {
LOG.fine("Processing queued action");
action.run();
} catch (Exception e) {
LOG.log(Level.WARNING, "Error executing queued action: " + e.getMessage(), e);
} finally {
actionInProgress = false;
if (!actionQueue.isEmpty()) {
processNextAction();
}
}
} else {
actionInProgress = false;
}
}
}
/**
* Check if an action is currently in progress, preventing the UI from accepting new actions
* while one is being processed.
*
* @return true if an action is in progress, false otherwise.
*/
public boolean isActionInProgress() {
return actionInProgress;
}
/** Ensures that the game state has been initialized before accessing it. */
@@ -92,19 +92,39 @@ public class GameClient {
}
public void sendCall() {
client.processCommand("CALL GAME_ID=" + gameId);
try {
client.processCommand("CALL GAME_ID=" + gameId);
LOG.fine("CALL command sent");
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send CALL: " + e.getMessage(), e);
}
}
public void sendFold() {
client.processCommand("FOLD GAME_ID=" + gameId);
try {
client.processCommand("FOLD GAME_ID=" + gameId);
LOG.fine("FOLD command sent");
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send FOLD: " + e.getMessage(), e);
}
}
public void sendBet(int amount) {
client.processCommand("BET GAME_ID=" + gameId + " AMOUNT=" + amount);
try {
client.processCommand("BET GAME_ID=" + gameId + " AMOUNT=" + amount);
LOG.fine("BET command sent: amount=" + amount);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send BET: " + e.getMessage(), e);
}
}
public void sendRaise(int amount) {
client.processCommand("RAISE GAME_ID=" + gameId + " AMOUNT=" + amount);
try {
client.processCommand("RAISE GAME_ID=" + gameId + " AMOUNT=" + amount);
LOG.fine("RAISE command sent: amount=" + amount);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to send RAISE: " + e.getMessage(), e);
}
}
private GameState parseGameState(String input) {
@@ -23,6 +23,9 @@ import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Pos;
@@ -33,6 +36,7 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
/**
* Controller for the casino gaming area.
@@ -122,6 +126,8 @@ public class CasinoGameController {
private static final int DEALER_PLAYER_3 = 2;
private static final int DEALER_MYSELF = 3;
private static final double CARD_START_OPACITY = 0.0;
private static final int TARGET_FPS = 30;
private static final double FRAME_TIME_MS = 1000.0 / TARGET_FPS;
private static final double CARD_START_SCALE = 0.85;
private static final double COMMUNITY_CARD_HEIGHT_RATIO = 0.22;
private static final double COMMUNITY_CARD_WIDTH_RATIO = 0.11;
@@ -149,16 +155,17 @@ public class CasinoGameController {
private static final double CHIP_TRANSLATE_RESET_Y = 0.0;
private static final double CHIP_FADE_TO = 1.0;
private static final double CHIP_SCALE_TO = 1.0;
private static final long CHIP_FADE_DURATION_MS = 200;
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 long CHIP_FADE_DURATION_MS = 100;
private static final long CHIP_SCALE_DURATION_MS = 110;
private static final long CHIP_DROP_DURATION_MS = 120;
private static final long CHIP_STAGGER_DELAY_MULTIPLIER = 15L;
private static final int MAX_POT_ROWS = 2;
private static final double POT_CHIP_H_GAP = 6.0;
private static final double POT_CHIP_V_GAP = 6.0;
private static final double POT_WIDTH_FACTOR = 0.45;
private static final double FALLBACK_POT_WIDTH = 500.0;
private static final double FALLBACK_CHIP_WIDTH = 36.0;
private volatile boolean updating = false;
private static final double CHAT_WIDTH = 400;
private static final double CHAT_HEIGHT = 600;
private static final String ACTIVE_CARD_BOX_STYLE_CLASS = "player-cards-active-turn";
@@ -178,6 +185,7 @@ public class CasinoGameController {
private static final int NO_BET = 0;
private boolean myDealerIconSizeBound;
private java.util.List<String> lobbyPlayerNames = java.util.List.of();
private static final int MAX_ANIMATED_CHIPS = 12;
/** Standard constructor. Used by FXML. */
public CasinoGameController() {
@@ -498,12 +506,24 @@ public class CasinoGameController {
private void startLoop() {
timeline =
new javafx.animation.Timeline(
new javafx.animation.KeyFrame(
javafx.util.Duration.seconds(UI_UPDATE_INTERVAL_SECONDS),
e -> updateUI()));
new Timeline(
new KeyFrame(
Duration.millis(FRAME_TIME_MS),
e -> {
if (updating) {
return;
}
timeline.setCycleCount(javafx.animation.Animation.INDEFINITE);
updating = true;
try {
updateUI();
} finally {
updating = false;
}
}));
timeline.setCycleCount(Animation.INDEFINITE);
timeline.play();
}
@@ -625,6 +645,12 @@ public class CasinoGameController {
updateGameInfo(s);
highlightDealer(s);
updateTaskbar(s);
if (s.pot != lastPot) {
lastPot = s.pot;
renderPot(s.pot);
}
clearActiveTurnHighlights();
announceLocalWinIfNeeded(s);
finishGameUiLoop();
@@ -644,6 +670,9 @@ public class CasinoGameController {
highlightDealer(s);
updateTaskbar(s);
// Force refresh of money display to handle UI stalls
refreshMoneyDisplay();
LOGGER.info("myPlayerId=" + myPlayerId);
for (int i = 0; i < players.size(); i++) {
Player p = players.get(i);
@@ -903,6 +932,30 @@ public class CasinoGameController {
controller.update(s, myPlayerName);
}
/**
* Force refresh of the money display in the taskbar to ensure it always reflects the current
* player's chip count, even during rapid game state changes.
*/
private void refreshMoneyDisplay() {
TaskbarController controller = resolveTaskbarController();
if (controller == null || gameService == null) {
return;
}
try {
List<Player> players = gameService.getPlayers();
if (players != null) {
for (Player p : players) {
if (isCurrentPlayer(p)) {
controller.setMoney(p.getChips());
return;
}
}
}
} catch (Exception e) {
LOGGER.warning("Could not refresh money display: " + e.getMessage());
}
}
/**
* 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
@@ -1829,6 +1882,10 @@ public class CasinoGameController {
* @param pot The total amount in the pot that needs to be represented with chips on the UI.
*/
private void renderPot(int pot) {
if (!javafx.application.Platform.isFxApplicationThread()) {
javafx.application.Platform.runLater(() -> renderPot(pot));
return;
}
potBox.getChildren().clear();
@@ -1869,7 +1926,13 @@ public class CasinoGameController {
bottomRow.getChildren().add(chip);
}
animateChipAppear(chip, index);
if (index < MAX_ANIMATED_CHIPS) {
animateChipAppear(chip, index);
} else {
chip.setOpacity(CHIP_FADE_TO);
chip.setScaleX(CHIP_SCALE_TO);
chip.setScaleY(CHIP_SCALE_TO);
}
remaining -= chipValue;
index++;
@@ -1961,10 +2024,7 @@ public class CasinoGameController {
*/
private void bindChipSize(ImageView view, javafx.scene.Scene scene) {
view.fitHeightProperty()
.bind(
scene.heightProperty().multiply(CHIP_HEIGHT_RATIO) // kleiner als Karten
);
view.fitHeightProperty().bind(scene.heightProperty().multiply(CHIP_HEIGHT_RATIO));
view.fitWidthProperty().bind(scene.widthProperty().multiply(CHIP_WIDTH_RATIO));
}
@@ -112,6 +112,7 @@ public class CasinoBrowserController {
TRUSTED_DOMAINS.add("mojeek.com");
TRUSTED_DOMAINS.add("searx.be");
TRUSTED_DOMAINS.add("startpage.com");
TRUSTED_DOMAINS.add("vscode.dev");
}
/** Aliases for common websites. */
@@ -138,7 +139,10 @@ public class CasinoBrowserController {
Map.entry("gradle", "gradle.org"),
Map.entry("spring", "spring.io"),
Map.entry("jetbrains", "jetbrains.com"),
Map.entry("uni", "unibas.ch"));
Map.entry("uni", "unibas.ch"),
Map.entry("vs", "vscode.dev"),
Map.entry("vscode", "vscode.dev"),
Map.entry("vs code", "vscode.dev"));
/**
* Resolves user input into a valid URL.
@@ -653,23 +657,22 @@ public class CasinoBrowserController {
}
if ("file".equalsIgnoreCase(scheme)) {
Path allowed =
Paths.get(
System.getProperty("user.dir"),
"documents",
"docs",
"game-engine",
"manual.html")
.normalize();
Path baseDir = Paths.get(System.getProperty("user.dir")).normalize();
Path manual = baseDir.resolve("documents/docs/game-engine/manual.html").normalize();
Path outreach = baseDir.resolve("outreach/index.html").normalize();
Path requested = Paths.get(uri).normalize();
if (requested.equals(allowed)) {
if (requested.equals(manual) || requested.equals(outreach)) {
securityLabel.setText("LOCAL OK");
engine.load(uri.toString());
} else {
securityLabel.setText("BLOCKED");
}
return;
}
@@ -91,6 +91,7 @@ public class TaskbarController {
private int lastPotSnapshot = 0;
private int lastObservedIncrease = 0;
private int lastRaiseIncrement = 0;
private static final long REFRESH_DELAY_MS = 500;
/** Standard constructor. Used by FXML. */
public TaskbarController() {
@@ -568,7 +569,17 @@ public class TaskbarController {
return;
}
if (isFirstFlopPlayer(state, me)) {
// if (isFirstFlopPlayer(state, me)) {
// setActionEnabled(betButton, true);
// setActionEnabled(callButton, false);
// // setActionEnabled(foldButton, false);
// setActionEnabled(raiseButton, false);
// activateInputField(taskbarInput);
// inputActionAllowed = true;
// return;
// }
if (isFirstPlayerOfPhase(state, me)) {
setActionEnabled(betButton, true);
setActionEnabled(callButton, false);
// setActionEnabled(foldButton, false);
@@ -754,6 +765,35 @@ public class TaskbarController {
return isInitialPreflopBlindLayout(state);
}
/**
* Finds the first player who can still act when starting from a specific index.
*
* @param state The current game state.
* @param startIndex The index from which the search should begin.
* @return The first eligible player index, or -1 if none can act.
*/
private int findFirstActingPlayerIndex(GameState state, int startIndex) {
if (state == null || state.players == null || state.players.isEmpty()) {
return -1;
}
int size = state.players.size();
int normalizedStart = ((startIndex % size) + size) % size;
for (int i = 0; i < size; i++) {
int candidate = (normalizedStart + i) % size;
Player player = state.players.get(candidate);
if (player != null
&& player.getState() != PlayerState.FOLDED
&& player.getChips() > 0) {
return candidate;
}
}
return -1;
}
/**
* Checks if the current player is the first to act on the flop.
*
@@ -783,19 +823,10 @@ public class TaskbarController {
return false;
}
int firstIndex = (state.dealer + 1) % size;
int firstIndex = findFirstActingPlayerIndex(state, (state.dealer + 1) % size);
for (int i = 0; i < size; i++) {
int candidate = (firstIndex + i) % size;
Player p = state.players.get(candidate);
if (p != null && p.getState() != PlayerState.FOLDED && p.getChips() > 0) {
firstIndex = candidate;
break;
}
if (firstIndex < 0) {
return false;
}
return state.activePlayer == firstIndex && myIndex == firstIndex;
@@ -828,14 +859,19 @@ public class TaskbarController {
return false;
}
int dealer = state.dealer;
int firstIndex;
if (isPreflop(state.phase)) {
firstIndex = (size == 2) ? dealer : (dealer + DEALER_OFFSET) % size;
firstIndex =
findFirstActingPlayerIndex(
state,
(size == 2) ? state.dealer : (state.dealer + DEALER_OFFSET) % size);
} else {
firstIndex = (dealer + 1) % size;
firstIndex = findFirstActingPlayerIndex(state, (state.dealer + 1) % size);
}
if (firstIndex < 0) {
return false;
}
return state.activePlayer == firstIndex && myIndex == firstIndex;
@@ -989,6 +1025,11 @@ public class TaskbarController {
return;
}
if (gameService.isActionInProgress()) {
LOGGER.info("Action {} blocked: another action is in progress", actionType);
return;
}
GameState state = ensureLatestStateForAction(actionType.name().toLowerCase());
if (state == null || isHandFinished(state)) {
if (state != null) {
@@ -1014,6 +1055,8 @@ public class TaskbarController {
return;
}
disableAllActionButtons();
executeAction(state, targetBet, actionType);
if (targetBet > 0) {
@@ -1021,7 +1064,35 @@ public class TaskbarController {
}
taskbarInput.clear();
refreshGame();
javafx.application.Platform.runLater(
() -> {
try {
Thread.sleep(REFRESH_DELAY_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
refreshGame();
});
}
/** Disables all action buttons to prevent multiple concurrent action submissions. */
private void disableAllActionButtons() {
if (betButton != null) {
betButton.setDisable(true);
}
if (callButton != null) {
callButton.setDisable(true);
}
if (raiseButton != null) {
raiseButton.setDisable(true);
}
if (foldButton != null) {
foldButton.setDisable(true);
}
if (taskbarInput != null) {
taskbarInput.setDisable(true);
}
}
/**
@@ -1791,6 +1862,24 @@ public class TaskbarController {
}
}
/**
* Opens the integrated Casono web browser.
*
* <p>once the content for strategies and support is available.
*/
@FXML
private void onBrowserButtonClickCasono() {
SoundManager.getInstance().playButtonClick();
try {
Path path = Paths.get(System.getProperty("user.dir"), "outreach", "index.html");
CasinoBrowserController.open(path.toUri().toString());
} catch (Exception e) {
e.printStackTrace();
}
}
/** Opens the integrated Casono Web Browser. */
@FXML
private void onBrowserButtonClickWiki() {
@@ -1815,6 +1904,18 @@ public class TaskbarController {
}
}
/** Opens the integrated Casono Web Browser. */
@FXML
private void onBrowserButtonClickVSCode() {
try {
CasinoBrowserController.open("https://vscode.dev/");
} catch (Exception e) {
e.printStackTrace();
}
}
/** Opens the tips notebook if it's currently closed. */
@FXML
private void onShowTipsButtonClick() {
@@ -8,6 +8,7 @@ import ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents.Highsco
import ch.unibas.dmi.dbis.cs108.casono.ui.sound.SoundManager;
import java.io.IOException;
import java.net.URL;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
@@ -26,6 +27,7 @@ import javafx.scene.layout.VBox;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -50,6 +52,11 @@ public class CasinomainuiController {
private int nextButtonId = 1;
private LobbyClient lobbyClient;
private ChatController chatController;
private PauseTransition titleResetTransition;
private static final String DEFAULT_TITLE = "Casono";
private static final String BLOCKED_USERNAME_TITLE =
"Name change blocked while lobby is active";
/** Default constructor used by FXMLLoader. */
public CasinomainuiController() {
@@ -63,7 +70,7 @@ public class CasinomainuiController {
*/
@FXML
public void initialize() {
titleLabel.setText("Casono");
titleLabel.setText(DEFAULT_TITLE);
subtitleLabel.setText("Texas Hold'em Poker");
logoView.setImage(new Image(getClass().getResource("/images/logo.png").toExternalForm()));
@@ -221,6 +228,11 @@ public class CasinomainuiController {
loginButton.setText("Change Name");
}
} catch (RuntimeException e) {
if (containsProtocolError(e, "CANNOT_CHANGE_USERNAME_DURING_LOBBY")
|| containsProtocolError(e, "RENAME_CONFLICT")) {
showTemporaryTitleMessage(BLOCKED_USERNAME_TITLE);
return;
}
LOGGER.error("Change username failed: {}", e.getMessage());
showAlert("Change username failed: " + e.getMessage());
}
@@ -246,6 +258,20 @@ public class CasinomainuiController {
}
}
private void showTemporaryTitleMessage(String message) {
if (titleLabel == null) {
return;
}
titleLabel.setText(message);
if (titleResetTransition != null) {
titleResetTransition.stop();
}
titleResetTransition = new PauseTransition(Duration.seconds(2));
titleResetTransition.setOnFinished(event -> titleLabel.setText(DEFAULT_TITLE));
titleResetTransition.playFromStart();
}
/** Shows an alert dialog with the given message. */
private void showAlert(String message) {
Alert alert = new Alert(AlertType.INFORMATION);
@@ -134,8 +134,7 @@ public class ChangeUsernameHandler extends CommandHandler<ChangeUsernameRequest>
dispatchError(
context,
"CANNOT_CHANGE_USERNAME_DURING_LOBBY",
"Cannot change username while actively playing in a lobby. "
+ "Wait until the game ends.");
"Name change blocked while lobby is active.");
return true;
}
return false;
@@ -98,9 +98,14 @@ public class GetGameStateHandler extends CommandHandler<GetGameStateRequest> {
}
try {
// Remove all active players
for (String playerName : lobby.getPlayerNames()) {
lobbyManager.removePlayer(playerName);
}
// Also remove all absent players so the lobby can be deleted
for (String playerName : lobby.getAbsentPlayers()) {
lobbyManager.removePlayer(playerName);
}
lobby.initGame(null);
} catch (RuntimeException e) {
LOGGER.log(
@@ -217,7 +217,7 @@ public class LobbyManager {
for (Map.Entry<LobbyId, Lobby> e : activeLobbies.entrySet()) {
LobbyId id = e.getKey();
Lobby l = e.getValue();
if (l.getPlayerNames().isEmpty()) {
if (!l.hasAnyPlayers()) {
Instant created = creationTimes.get(id);
if (created != null && created.isBefore(cutoff)) {
result.add(id);
@@ -24,9 +24,11 @@
<MenuButton text="HELP" styleClass="gray-button">
<items>
<MenuItem text="SHOW TIPS" onAction="#onShowTipsButtonClick" />
<MenuItem text="CASONO BROWSER: Manual" onAction="#onBrowserButtonClick" />
<MenuItem text="CASONO BROWSER: Wikipedia" onAction="#onBrowserButtonClickWiki" />
<MenuItem text="CASONO BROWSER: Brave Search" onAction="#onBrowserButtonClickBrave" />
<MenuItem text="CB: Manual" onAction="#onBrowserButtonClick" />
<MenuItem text="CB: Casono" onAction="#onBrowserButtonClickCasono" />
<MenuItem text="CB: Wikipedia" onAction="#onBrowserButtonClickWiki" />
<MenuItem text="CB: Brave Search" onAction="#onBrowserButtonClickBrave" />
<MenuItem text="CB: VS Code" onAction="#onBrowserButtonClickVSCode" />
</items>
</MenuButton>