From fe6d4e9e5f2fd7c3c010c150377ce7b3c5e5f42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 24 Aug 2026 10:09:37 +0200 Subject: [PATCH 01/14] [Server] Implement Swiss tournament engine for lobby games Adds the server-side tournament mode driven through the strategy and factory interfaces from #7132: - Server_Tournament runs greedy Swiss pairing with rematch avoidance, byes, ceil(log2(n)) rounds, and best-of-N series via gamesPerMatch; match sub-games are spawned through the parent game's Server_MatchGameFactory implementation with players auto-joined and their submitted decks installed - Tournament lifecycle strategy gates the hub game start until all decks are submitted; match result strategy reports sub-game outcomes back to the parent tournament - Server_Game gains tournament state (isTournament flag, settings, parent link), protocol fields in getInfo/game state events, a full-lobby bypass for tournament hubs, and disconnectRemovesPlayer plumbing for upcoming draft modes - Match-game creation is deferred to the owning thread's event loop so room registration never nests lock orders; shared state is guarded by the tournament mutex --- .../network/server/remote/CMakeLists.txt | 8 +- .../game/server_abstract_participant.cpp | 2 +- .../server/remote/game/server_game.cpp | 101 +++- .../network/server/remote/game/server_game.h | 61 +- .../server/remote/game/server_tournament.cpp | 556 ++++++++++++++++++ .../server/remote/game/server_tournament.h | 106 ++++ .../server_tournament_lifecycle_strategy.cpp | 33 ++ .../server_tournament_lifecycle_strategy.h | 12 + ...erver_tournament_match_result_strategy.cpp | 34 ++ .../server_tournament_match_result_strategy.h | 12 + .../server/remote/server_protocolhandler.cpp | 8 + 11 files changed, 927 insertions(+), 6 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 26c2be69a..b429b8eb4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -15,9 +15,12 @@ set(HEADERS game/server_deck_validation_strategy.h game/server_game.h game/server_game_lifecycle_strategy.h - game/server_match_result_strategy.h game/server_match_game_factory.h + game/server_match_result_strategy.h game/server_player.h + game/server_tournament.h + game/server_tournament_lifecycle_strategy.h + game/server_tournament_match_result_strategy.h game/server_spectator.h server.h server_abstractuserinterface.h @@ -43,6 +46,9 @@ add_library( game/server_game.cpp game/server_player.cpp game/server_spectator.cpp + game/server_tournament.cpp + game/server_tournament_lifecycle_strategy.cpp + game/server_tournament_match_result_strategy.cpp server.cpp server_abstractuserinterface.cpp server_database_interface.cpp diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 6c5d557b1..96ff74dd4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -587,7 +587,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface * void Server_AbstractParticipant::disconnectClient() { bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered; - if (!isRegistered || spectator) { + if (!isRegistered || spectator || game->getDisconnectRemovesPlayer()) { game->removeParticipant(this, Event_Leave::USER_DISCONNECTED); } else { setUserInterface(nullptr); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index b1863c91e..295cbebf0 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -49,6 +49,9 @@ #include "server_player.h" #include "server_response_containers.h" #include "server_spectator.h" +#include "server_tournament.h" +#include "server_tournament_lifecycle_strategy.h" +#include "server_tournament_match_result_strategy.h" #include #include @@ -59,6 +62,7 @@ #include #include #include +#include #include #include #include @@ -91,8 +95,9 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), - turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), - deckValidationStrategy(new Server_DefaultDeckValidationStrategy), + turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), isTournament(false), + tournament(nullptr), tournamentParentGame(nullptr), tournamentMatchPlayer1Id(-1), tournamentMatchPlayer2Id(-1), + disconnectRemovesPlayer(false), deckValidationStrategy(new Server_DefaultDeckValidationStrategy), lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy), gameMutex() { @@ -305,6 +310,12 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } + event->set_is_tournament(isTournament); + + if (tournamentParentGame) { + event->set_parent_game_id(tournamentParentGame->getGameId()); + } + for (Server_AbstractParticipant *participant : participants.values()) { participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo); } @@ -353,7 +364,7 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface(); QMutexLocker locker(&gameMutex); - if (getPlayerCount() < maxPlayers && !forceStartGame) { + if (!isTournament && getPlayerCount() < maxPlayers && !forceStartGame) { return; } @@ -947,6 +958,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const result.set_share_decklists_on_load(shareDecklistsOnLoad); result.set_spectators_count(getSpectatorCount()); result.set_start_time(startTime.toSecsSinceEpoch()); + result.set_is_tournament(isTournament); } } @@ -997,3 +1009,86 @@ void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strat { deckValidationStrategy.reset(strategy); } + +void Server_Game::setMatchResultStrategy(Server_MatchResultStrategy *strategy) +{ + matchResultStrategy.reset(strategy); +} + +void Server_Game::setIsTournamentGame(bool _isTournament) +{ + isTournament = _isTournament; + if (isTournament) { + tournament = new Server_Tournament(this, this, this); + lifecycleStrategy.reset(new Server_TournamentLifecycleStrategy); + matchResultStrategy.reset(new Server_TournamentMatchResultStrategy); + } else if (tournament) { + delete tournament; + tournament = nullptr; + lifecycleStrategy.reset(new Server_DefaultLifecycleStrategy); + matchResultStrategy.reset(new Server_NullMatchResultStrategy); + } +} + +void Server_Game::startTournament() +{ + if (!tournament) { + tournament = new Server_Tournament(this, this, this); + } + + if (!tournament->isStarted()) { + // Add all current players to the tournament + auto players = getPlayers(); + for (auto *player : players.values()) { + tournament->addPlayer(player->getPlayerId(), QString::fromStdString(player->getUserInfo()->name())); + } + + tournament->startTournament(); + } + + GameEventStorage ges; + tournament->broadcastTournamentState(ges); + ges.sendToGame(this); +} + +void Server_Game::setPlayerTournamentDeck(int playerId, DeckList *deck) +{ + if (tournament) { + tournament->setPlayerDeck(playerId, deck); + } +} + +void Server_Game::setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id) +{ + tournamentParentGame = parentGame; + tournamentMatchPlayer1Id = p1Id; + tournamentMatchPlayer2Id = p2Id; +} + +Server_Game *Server_Game::createMatchGame(const GameConfig &config, int &outGameId) +{ + Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface(); + outGameId = databaseInterface->getNextGameId(); + if (outGameId == -1) { + return nullptr; + } + + GameConfig matchConfig = config; + matchConfig.gameId = outGameId; + auto *game = new Server_Game(matchConfig, room); + // Sub-games carry the tournament flag (for protocol fields) but keep the default + // strategies; the parent tournament drives them through the match result strategy + // installed by Server_Tournament::createMatchGame. + game->isTournament = true; + return game; +} + +Server_AbstractUserInterface *Server_Game::getUserInterface(const QString &playerName) +{ + return room->getUserInterfaceByName(playerName); +} + +void Server_Game::addGameToRoom(Server_Game *game) +{ + room->addGame(game); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index c7f8d0929..3b8d794dc 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -21,7 +21,12 @@ #define SERVERGAME_H #include "../server_response_containers.h" +#include "game_config.h" #include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include "server_deck_validation_strategy.h" +#include "server_game_lifecycle_strategy.h" +#include "server_match_game_factory.h" +#include "server_match_result_strategy.h" // IWYU pragma: keep // ServerInfo_Game is passed by value to the gameInfoChanged() signal, so the @@ -37,16 +42,21 @@ #include #include #include +#include #include #include #include class QTimer; +class DeckList; class GameEventContainer; +class GameEventStorage; class GameReplay; class Server_Room; class Server_AbstractPlayer; class Server_AbstractParticipant; +class Server_Card; +class Server_Tournament; class ServerInfo_User; class ServerInfo_Game; class Server_AbstractUserInterface; @@ -63,7 +73,7 @@ class Message; } // namespace google struct GameConfig; -class Server_Game : public QObject +class Server_Game : public QObject, public Server_MatchGameFactory { Q_OBJECT private: @@ -98,6 +108,14 @@ private: QList replayList; GameReplay *currentReplay; + bool isTournament; + TournamentSettings tournamentSettings; + Server_Tournament *tournament; + Server_Game *tournamentParentGame; + int tournamentMatchPlayer1Id; + int tournamentMatchPlayer2Id; + bool disconnectRemovesPlayer; + QScopedPointer deckValidationStrategy; QScopedPointer lifecycleStrategy; @@ -237,6 +255,45 @@ public: void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); /** @brief Get the current deck validation strategy (non-owning). */ + bool getIsTournamentGame() const + { + return isTournament; + } + void setIsTournamentGame(bool _isTournament); + bool getIsTournament() const + { + return tournament != nullptr; + } + Server_Tournament *getTournament() const + { + return tournament; + } + void startTournament(); + void setPlayerTournamentDeck(int playerId, DeckList *deck); + void setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id); + Server_Game *getTournamentParentGame() const + { + return tournamentParentGame; + } + bool getDisconnectRemovesPlayer() const + { + return disconnectRemovesPlayer; + } + + // Server_MatchGameFactory implementation + Server_Game *createMatchGame(const GameConfig &config, int &outGameId) override; + Server_AbstractUserInterface *getUserInterface(const QString &playerName) override; + void addGameToRoom(Server_Game *game) override; + + const TournamentSettings &getTournamentSettings() const + { + return tournamentSettings; + } + void setTournamentSettings(const TournamentSettings &settings) + { + tournamentSettings = settings; + } + Server_DeckValidationStrategy *getDeckValidationStrategy() const { return deckValidationStrategy.data(); @@ -249,6 +306,8 @@ public: { return lifecycleStrategy.data(); } + /** @brief Replace the match result strategy; takes ownership of @p strategy. */ + void setMatchResultStrategy(Server_MatchResultStrategy *strategy); }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp new file mode 100644 index 000000000..1d13f31f7 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -0,0 +1,556 @@ +#include "server_tournament.h" + +#include "../server_abstractuserinterface.h" +#include "../server_response_containers.h" +#include "../serverinfo_user_container.h" +#include "game_config.h" +#include "server_abstract_player.h" +#include "server_game.h" +#include "server_match_game_factory.h" +#include "server_player.h" +#include "server_tournament_match_result_strategy.h" + +#include +#include +#include +#include +#include + +inline Q_LOGGING_CATEGORY(TournamentLog, "tournament"); + +Server_Tournament::Server_Tournament(Server_Game *_parentGame, Server_MatchGameFactory *_factory, QObject *parent) + : QObject(parent), parentGame(_parentGame), matchGameFactory(_factory), currentRound(0), totalRounds(0), + started(false) +{ +} + +Server_Tournament::~Server_Tournament() +{ + qDeleteAll(submittedDecks); +} + +void Server_Tournament::addPlayer(int playerId, const QString &playerName) +{ + TournamentPlayerData data; + data.playerId = playerId; + data.playerName = playerName; + players[playerId] = data; +} + +void Server_Tournament::setPlayerDeck(int playerId, DeckList *deck) +{ + QMutexLocker locker(&tournamentMutex); + delete submittedDecks.value(playerId, nullptr); + submittedDecks[playerId] = deck; + if (players.contains(playerId)) { + players[playerId].deckSubmitted = true; + } +} + +void Server_Tournament::removePlayer(int playerId) +{ + QMutexLocker locker(&tournamentMutex); + players.remove(playerId); +} + +void Server_Tournament::startTournament() +{ + { + QMutexLocker locker(&tournamentMutex); + if (started) { + return; + } + totalRounds = calculateTotalRounds(); + started = true; + currentRound = 0; + generateSwissPairings(); + } + + // Spawn the first round's match games + enqueueMatchGameCreation(); +} + +bool Server_Tournament::isAllDecksSubmitted() const +{ + QMutexLocker locker(&tournamentMutex); + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (!it->deckSubmitted) { + return false; + } + } + return true; +} + +int Server_Tournament::getTournamentPlayerIdByName(const QString &name) const +{ + QMutexLocker locker(&tournamentMutex); + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (it->playerName == name) { + return it->playerId; + } + } + return -1; +} + +void Server_Tournament::generateSwissPairings() +{ + currentPairings.clear(); + QList available; + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + available.append(it->playerId); + } + + // Sort by wins descending (and by record for tie-breaking) + std::sort(available.begin(), available.end(), [this](int a, int b) { + const auto &pa = players[a]; + const auto &pb = players[b]; + if (pa.wins != pb.wins) { + return pa.wins > pb.wins; + } + if (pa.losses != pb.losses) { + return pa.losses < pb.losses; + } + return a < b; + }); + + // Simple greedy Swiss pairing + QSet paired; + for (int i = 0; i < available.size(); ++i) { + if (paired.contains(available[i])) { + continue; + } + for (int j = i + 1; j < available.size(); ++j) { + if (paired.contains(available[j])) { + continue; + } + if (!havePlayed(available[i], available[j])) { + TournamentPairingData pairing; + pairing.player1Id = available[i]; + pairing.player2Id = available[j]; + currentPairings.append(pairing); + paired.insert(available[i]); + paired.insert(available[j]); + break; + } + } + } + + // Bye for unpaired player if odd count + for (int i = 0; i < available.size(); ++i) { + if (!paired.contains(available[i])) { + // Player gets a bye (auto-win) + TournamentPairingData bye; + bye.player1Id = available[i]; + bye.player2Id = -1; + bye.winnerId = available[i]; + bye.player1MatchWins = gamesPerMatch; // Match immediately decided + currentPairings.append(bye); + players[available[i]].wins += 1; + allPreviousPairings.append(qMakePair(available[i], -1)); + break; + } + } +} + +int Server_Tournament::calculateTotalRounds() const +{ + int n = players.size(); + if (n <= 1) { + return 0; + } + // Standard Swiss rounds: ceil(log2(n)) + int rounds = 0; + while ((1 << rounds) < n) { + ++rounds; + } + return rounds; +} + +bool Server_Tournament::havePlayed(int p1, int p2) const +{ + for (const auto &pair : allPreviousPairings) { + if ((pair.first == p1 && pair.second == p2) || (pair.first == p2 && pair.second == p1)) { + return true; + } + } + return false; +} + +void Server_Tournament::advanceRound(GameEventStorage &ges) +{ + { + QMutexLocker locker(&tournamentMutex); + ++currentRound; + if (currentRound >= totalRounds) { + broadcastTournamentState(ges); + return; + } + generateSwissPairings(); + } + + enqueueMatchGameCreation(); + broadcastTournamentState(ges); +} + +bool Server_Tournament::allPairingsDecided() const +{ + for (const auto &pairing : currentPairings) { + if (pairing.winnerId == -2) { + return false; + } + } + return true; +} + +void Server_Tournament::enqueueMatchGameCreation() +{ + QList> planned; + { + QMutexLocker locker(&tournamentMutex); + for (const auto &pairing : currentPairings) { + if (pairing.player2Id != -1 && pairing.winnerId == -2 && + pairing.matchGameIds.size() < static_cast(gamesPerMatch)) { + planned.append(qMakePair(pairing.player1Id, pairing.player2Id)); + } + } + } + if (planned.isEmpty()) { + return; + } + + // Create the games from the event loop instead of the caller's stack: command + // processing holds game mutexes, and room registration takes gamesLock, so spawning + // synchronously would nest lock orders. The queued job runs once this object's + // owning thread returns to its event loop with no locks held; it is dropped if this + // tournament is destroyed first. + QMetaObject::invokeMethod( + this, + [this, planned] { + for (const auto &pair : planned) { + createMatchGame(pair.first, pair.second); + } + + GameEventStorage ges; + broadcastTournamentState(ges); + ges.sendToGame(parentGame); + }, + Qt::QueuedConnection); +} + +void Server_Tournament::createMatchGame(int player1Id, int player2Id) +{ + if (!matchGameFactory || player2Id == -1) { + return; + } + + QString player1Name; + QString player2Name; + QString deck1Native; + QString deck2Native; + int round = 0; + int gameNumber = 1; + { + QMutexLocker locker(&tournamentMutex); + player1Name = players.value(player1Id).playerName; + player2Name = players.value(player2Id).playerName; + + if (submittedDecks.contains(player1Id)) { + deck1Native = submittedDecks.value(player1Id)->writeToString_Native(); + } + if (submittedDecks.contains(player2Id)) { + deck2Native = submittedDecks.value(player2Id)->writeToString_Native(); + } + + round = currentRound; + for (const auto &pairing : currentPairings) { + if (pairing.player1Id == player1Id && pairing.player2Id == player2Id) { + gameNumber = pairing.matchGameIds.size() + 1; + break; + } + } + + // Defense in depth: never exceed the configured series length + for (const auto &pairing : currentPairings) { + if (pairing.player1Id == player1Id && pairing.player2Id == player2Id && + pairing.matchGameIds.size() >= static_cast(gamesPerMatch)) { + qCWarning(TournamentLog) << "Refusing to exceed series length for pairing" << player1Id << player2Id; + return; + } + } + } + + // Create a sub-game for this match via the factory + ServerInfo_User creatorInfo; + creatorInfo.set_name(player1Name.toStdString()); + creatorInfo.set_user_level(ServerInfo_User::IsAdmin | ServerInfo_User::IsRegistered); + + QString gameDesc = gamesPerMatch > 1 + ? QString("R%1 Match - Game %2 of %3").arg(round).arg(gameNumber).arg(gamesPerMatch) + : QString("Tournament Round %1").arg(round); + + GameConfig matchConfig; + matchConfig.creatorInfo = creatorInfo; + matchConfig.description = gameDesc; + matchConfig.maxPlayers = 2; + matchConfig.startingLifeTotal = parentGame->getStartingLifeTotal(); + + int matchGameId = -1; + auto *matchGame = matchGameFactory->createMatchGame(matchConfig, matchGameId); + if (!matchGame || matchGameId == -1) { + return; + } + + matchGame->setTournamentMatchInfo(parentGame, player1Id, player2Id); + matchGame->setMatchResultStrategy(new Server_TournamentMatchResultStrategy); + matchGameFactory->addGameToRoom(matchGame); + + // Store the game ID in the pairing + { + QMutexLocker locker(&tournamentMutex); + for (auto &pairing : currentPairings) { + if (pairing.player1Id == player1Id && pairing.player2Id == player2Id) { + pairing.gameId = matchGameId; + pairing.matchGameIds.append(matchGameId); + break; + } + } + } + + // Auto-join both players, sending the join event directly through their UIs. + QMap> joiners; + + auto joinAndSetupPlayer = [&](int pid, const QString &name) { + Server_AbstractUserInterface *ui = matchGameFactory->getUserInterface(name); + if (ui) { + auto *rc = new ResponseContainer(0); + matchGame->addPlayer(ui, *rc, false, false, false); + joiners[pid] = qMakePair(ui, rc); + } + }; + + joinAndSetupPlayer(player1Id, player1Name); + joinAndSetupPlayer(player2Id, player2Name); + + // Now send the enqueued GameJoined + GameStateChanged events to each player's client. + for (auto it = joiners.constBegin(); it != joiners.constEnd(); ++it) { + it.value().first->sendResponseContainer(*it.value().second, Response::RespNothing); + delete it.value().second; + } + joiners.clear(); + + // Set decks and mark players as ready in the match game + auto matchPlayers = matchGame->getPlayers(); + for (auto *matchPlayer : matchPlayers) { + const QString name = QString::fromStdString(matchPlayer->getUserInfo()->name()); + QString deckNative; + if (name == player1Name) { + deckNative = deck1Native; + } else if (name == player2Name) { + deckNative = deck2Native; + } + + if (!deckNative.isEmpty()) { + matchPlayer->setDeck(new DeckList(deckNative)); + matchPlayer->setReadyStart(true); + } + } + + // Start the match game + matchGame->startGameIfReady(true); +} + +void Server_Tournament::recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges) +{ + QMutexLocker locker(&tournamentMutex); + + // Find the pairing and set the winner + for (auto &pairing : currentPairings) { + if ((pairing.player1Id == playerId1 && pairing.player2Id == playerId2) || + (pairing.player1Id == playerId2 && pairing.player2Id == playerId1)) { + if (pairing.winnerId != -2) { + return; // Already recorded — defense in depth against double-call + } + pairing.winnerId = winnerId; + break; + } + } + + // Update player records + if (winnerId == -1) { + // Draw + players[playerId1].draws += 1; + players[playerId2].draws += 1; + } else if (winnerId == playerId1) { + players[playerId1].wins += 1; + players[playerId2].losses += 1; + } else if (winnerId == playerId2) { + players[playerId2].wins += 1; + players[playerId1].losses += 1; + } + + // Store for future pairing avoidance + allPreviousPairings.append(qMakePair(playerId1, playerId2)); + + // Check if all pairings in current round have results + bool allDecided = true; + for (const auto &pairing : currentPairings) { + if (pairing.winnerId == -2) { + allDecided = false; + break; + } + } + + broadcastTournamentState(ges); + + if (allDecided) { + advanceRound(ges); + } +} + +bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges) +{ + bool matchDecided = false; + bool seriesContinues = false; + int p1 = -1; + int p2 = -1; + { + QMutexLocker locker(&tournamentMutex); + + // Find the pairing that owns this game + TournamentPairingData *pairingPtr = nullptr; + for (auto &pairing : currentPairings) { + if (pairing.matchGameIds.contains(gameId)) { + pairingPtr = &pairing; + break; + } + } + + if (!pairingPtr) { + return false; + } + + // If the match is already decided, ignore further sub-game results + if (pairingPtr->winnerId != -2) { + return true; + } + + // Increment per-match wins + if (winnerId == pairingPtr->player1Id) { + pairingPtr->player1MatchWins += 1; + } else if (winnerId == pairingPtr->player2Id) { + pairingPtr->player2MatchWins += 1; + } + // Draw (winnerId == -1): no match wins incremented + + // Check if match is decided + const int gamesNeeded = static_cast(gamesPerMatch); + matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded); + + if (matchDecided) { + // Determine match winner + int matchWinnerId; + if (pairingPtr->player1MatchWins >= gamesNeeded) { + matchWinnerId = pairingPtr->player1Id; + } else { + matchWinnerId = pairingPtr->player2Id; + } + + // Set the match winner on the pairing + pairingPtr->winnerId = matchWinnerId; + + // Update tournament-level player records + if (matchWinnerId == pairingPtr->player1Id) { + players[pairingPtr->player1Id].wins += 1; + players[pairingPtr->player2Id].losses += 1; + } else { + players[pairingPtr->player2Id].wins += 1; + players[pairingPtr->player1Id].losses += 1; + } + + // Store for future pairing avoidance + allPreviousPairings.append(qMakePair(pairingPtr->player1Id, pairingPtr->player2Id)); + } else { + // Match not decided — spawn the next sub-game outside all locks + seriesContinues = true; + p1 = pairingPtr->player1Id; + p2 = pairingPtr->player2Id; + } + + broadcastTournamentState(ges); + } + + if (seriesContinues) { + QMetaObject::invokeMethod( + this, + [this, p1, p2] { + createMatchGame(p1, p2); + + GameEventStorage nextGes; + broadcastTournamentState(nextGes); + nextGes.sendToGame(parentGame); + }, + Qt::QueuedConnection); + } + + checkAndAdvanceRound(ges); + + return matchDecided; +} + +void Server_Tournament::checkAndAdvanceRound(GameEventStorage &ges) +{ + bool roundComplete = false; + { + QMutexLocker locker(&tournamentMutex); + roundComplete = allPairingsDecided(); + } + + if (roundComplete) { + advanceRound(ges); + } +} + +void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) +{ + QMutexLocker locker(&tournamentMutex); + + Event_TournamentState state; + + if (started && currentRound >= totalRounds) { + state.set_phase(Event_TournamentState::PHASE_FINISHED); + } else if (started) { + state.set_phase(Event_TournamentState::PHASE_PLAYING); + } else { + state.set_phase(Event_TournamentState::PHASE_DECK_BUILDING); + } + + state.set_current_round(currentRound); + state.set_total_rounds(totalRounds); + + // Settings + TournamentSettings *settings = state.mutable_settings(); + settings->set_games_per_match(gamesPerMatch); + + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + TournamentPlayer *p = state.add_players(); + p->set_player_id(it->playerId); + p->set_player_name(it->playerName.toStdString()); + p->set_wins(it->wins); + p->set_losses(it->losses); + p->set_draws(it->draws); + p->set_deck_submitted(it->deckSubmitted); + } + + for (const auto &pairing : currentPairings) { + TournamentPairing *p = state.add_pairings(); + p->set_player1_id(pairing.player1Id); + p->set_player2_id(pairing.player2Id); + p->set_game_id(pairing.gameId); + // Map internal sentinel: -2 (undecided) -> -1 (no winner yet in proto) + p->set_winner_id(pairing.winnerId == -2 ? -1 : pairing.winnerId); + p->set_player1_match_wins(pairing.player1MatchWins); + p->set_player2_match_wins(pairing.player2MatchWins); + } + + ges.enqueueGameEvent(state, -1); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h new file mode 100644 index 000000000..b59edda4f --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -0,0 +1,106 @@ +#ifndef SERVER_TOURNAMENT_H +#define SERVER_TOURNAMENT_H + +#include +#include +#include +#include +#include +#include + +class DeckList; +class Server_Game; +class Server_MatchGameFactory; +class Server_AbstractParticipant; +class Server_AbstractUserInterface; +class GameEventStorage; + +/** @brief Maximum number of games per match a tournament can be configured with. */ +constexpr int MAX_GAMES_PER_MATCH = 5; + +class Server_Tournament : public QObject +{ + Q_OBJECT +public: + explicit Server_Tournament(Server_Game *_parentGame, Server_MatchGameFactory *_factory, QObject *parent = nullptr); + ~Server_Tournament() override; + + void addPlayer(int playerId, const QString &playerName); + void removePlayer(int playerId); + void startTournament(); + void advanceRound(GameEventStorage &ges); + void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges); + bool recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges); + void broadcastTournamentState(GameEventStorage &ges); + + bool isStarted() const + { + return started; + } + bool isAllDecksSubmitted() const; + int getPlayerCount() const + { + return players.size(); + } + int getTournamentPlayerIdByName(const QString &name) const; + void setPlayerDeckSubmitted(int playerId) + { + if (players.contains(playerId)) { + players[playerId].deckSubmitted = true; + } + } + void setPlayerDeck(int playerId, DeckList *deck); + + void setGamesPerMatch(uint32_t n) + { + gamesPerMatch = n; + } + uint32_t getGamesPerMatch() const + { + return gamesPerMatch; + } + + struct TournamentPlayerData + { + int playerId; + QString playerName; + int wins = 0; + int losses = 0; + int draws = 0; + bool deckSubmitted = false; + }; + + struct TournamentPairingData + { + int player1Id; + int player2Id; + int gameId = -1; + int winnerId = -2; // -2 = undecided, -1 = draw, >= 0 = winner player id + int player1MatchWins = 0; + int player2MatchWins = 0; + QList matchGameIds; + }; + +private: + Server_Game *parentGame; + Server_MatchGameFactory *matchGameFactory; + mutable QRecursiveMutex tournamentMutex; + QMap players; + QMap submittedDecks; + QList currentPairings; + QList> allPreviousPairings; + int currentRound; + int totalRounds; + bool started; + uint32_t gamesPerMatch = 1; + + void generateSwissPairings(); + int calculateTotalRounds() const; + bool havePlayed(int p1, int p2) const; + bool allPairingsDecided() const; + void createMatchGame(int player1Id, int player2Id); + void enqueueMatchGameCreation(); + void checkAndAdvanceRound(GameEventStorage &ges); +}; + +#endif // SERVER_TOURNAMENT_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp new file mode 100644 index 000000000..5149b9a26 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp @@ -0,0 +1,33 @@ +#include "server_tournament_lifecycle_strategy.h" + +#include "server_abstract_player.h" +#include "server_game.h" + +#include + +inline Q_LOGGING_CATEGORY(TournamentLifecycleLog, "tournament_lifecycle"); + +Server_GameLifecycleStrategy::StartAction Server_TournamentLifecycleStrategy::onGameStarting(Server_Game *game) +{ + // Match sub-games start through the normal flow; only the tournament hub game is + // managed by this lifecycle. + if (game->getTournamentParentGame() != nullptr) { + return StartAction::ProceedNormal; + } + + for (auto *player : game->getPlayers().values()) { + if (!player->getDeckList()) { + qCWarning(TournamentLifecycleLog) + << "Tournament cannot start: player" << player->getUserInfo()->name().c_str() << "has no deck"; + return StartAction::Handled; + } + } + + if (!game->getIsTournamentGame()) { + qCWarning(TournamentLifecycleLog) << "Tournament lifecycle used for non-tournament game — falling back"; + return StartAction::ProceedNormal; + } + + game->startTournament(); + return StartAction::Handled; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h new file mode 100644 index 000000000..c6dbede1b --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h @@ -0,0 +1,12 @@ +#ifndef SERVER_TOURNAMENT_LIFECYCLE_STRATEGY_H +#define SERVER_TOURNAMENT_LIFECYCLE_STRATEGY_H + +#include "server_game_lifecycle_strategy.h" + +class Server_TournamentLifecycleStrategy : public Server_GameLifecycleStrategy +{ +public: + StartAction onGameStarting(Server_Game *game) override; +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp new file mode 100644 index 000000000..5611224e5 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp @@ -0,0 +1,34 @@ +#include "server_tournament_match_result_strategy.h" + +#include "../server_response_containers.h" +#include "server_abstract_player.h" +#include "server_game.h" +#include "server_tournament.h" + +#include +#include + +bool Server_TournamentMatchResultStrategy::onGameFinished(Server_Game *game, + int playing, + Server_AbstractPlayer *lastPlayer) +{ + auto *parentGame = game->getTournamentParentGame(); + if (!parentGame || !parentGame->getTournament()) { + return false; + } + + int winnerId; + if (playing == 0) { + winnerId = -1; + } else { + QString winnerName = QString::fromStdString(lastPlayer->getUserInfo()->name()); + auto *tournament = parentGame->getTournament(); + winnerId = tournament->getTournamentPlayerIdByName(winnerName); + } + + GameEventStorage parentGes; + bool matchDecided = parentGame->getTournament()->recordMatchResultByGameId(game->getGameId(), winnerId, parentGes); + parentGes.sendToGame(parentGame); + + return matchDecided; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h new file mode 100644 index 000000000..7bc3bafb8 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h @@ -0,0 +1,12 @@ +#ifndef SERVER_TOURNAMENT_MATCH_RESULT_STRATEGY_H +#define SERVER_TOURNAMENT_MATCH_RESULT_STRATEGY_H + +#include "server_match_result_strategy.h" + +class Server_TournamentMatchResultStrategy : public Server_MatchResultStrategy +{ +public: + bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) override; +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index dd074a517..eacb9802c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -6,6 +6,8 @@ static constexpr qint64 kAuthNonceLifetimeSeconds = 60; #include "game/game_config.h" #include "game/server_abstract_participant.h" #include "game/server_game.h" +#include "game/server_player.h" +#include "game/server_tournament.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/developer_commands.pb.h" #include "libcockatrice/protocol/pb/game_commands.pb.h" @@ -1094,6 +1096,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20; bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false; + bool isTournament = cmd.has_is_tournament() ? cmd.is_tournament() : false; + int gamesPerMatch = cmd.has_games_per_match() ? static_cast(cmd.games_per_match()) : 1; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { @@ -1118,6 +1122,10 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room .shareDecklistsOnLoad = shareDecklistsOnLoad}; auto *game = new Server_Game(config, room); + game->setIsTournamentGame(isTournament); + if (isTournament && game->getTournament()) { + game->getTournament()->setGamesPerMatch(static_cast(qBound(1, gamesPerMatch, MAX_GAMES_PER_MATCH))); + } game->addPlayer(this, rc, asSpectator, asJudge, false); room->addGame(game); From e44ddc1138c30f554c45bc8cec000dcf1cf9c00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 08:57:26 +0200 Subject: [PATCH 02/14] [Server] Harden Swiss tournament engine against drops and stalls - link match sub-games to the hub through QPointer so a finished match can never dereference a torn-down parent game - a player who leaves the hub is dropped: no longer paired, current undecided match awarded to the opponent, absent from the bracket - refuse to spawn a match game when either participant is disconnected, and set disconnectRemovesPlayer on match games so a mid-match disconnect ends it instead of leaving a half-present participant - match winner is a strict majority (gamesPerMatch/2+1); an exhausted series with no majority is recorded as a draw so the round always advances - match games are started without force-start: a missing deck no longer kicks the player; the game stays open for deck selection - buyes are handed to every leftover player, worst-ranked first, at most one per player over the tournament - tournament hubs cannot start on mere 'everyone ready': host force-start is required and fewer than two players never starts - sub-game creator copies the real player user info instead of fabricating IsAdmin --- .../server/remote/game/server_game.cpp | 24 ++ .../network/server/remote/game/server_game.h | 9 +- .../server/remote/game/server_tournament.cpp | 212 ++++++++++++++---- .../server/remote/game/server_tournament.h | 10 +- .../server_tournament_lifecycle_strategy.cpp | 2 +- ...erver_tournament_match_result_strategy.cpp | 5 +- 6 files changed, 216 insertions(+), 46 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 295cbebf0..f536dd3ca 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -368,6 +368,13 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) return; } + // Tournament hubs must be host-started and can't lock in a partially filled + // bracket: a mere "everyone current is ready" must not start a 1-player or + // undersized tournament. startTournament() additionally enforces 2+ players. + if (isTournament && !forceStartGame) { + return; + } + auto players = getPlayers(); for (auto *player : players.values()) { if (!player->getReadyStart()) { @@ -622,6 +629,17 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve bool playerHost = hostId == participant->getPlayerId(); participant->prepareDestroy(); + // If this is the tournament hub (not one of its match sub-games), never re-pair + // the leaving player: mark them dropped so their matches are awarded and they + // disappear from the bracket instead of stalling the tournament. + if (tournament && !tournamentParentGame && !spectator) { + const int leavingPlayerId = participant->getPlayerId(); + GameEventStorage tournGes; + tournament->dropPlayer(leavingPlayerId); + tournament->broadcastTournamentState(tournGes); + tournGes.sendToGame(this); + } + if (playerHost) { int newHostId = -1; for (auto *otherPlayer : getPlayers().values()) { @@ -1043,6 +1061,12 @@ void Server_Game::startTournament() tournament->addPlayer(player->getPlayerId(), QString::fromStdString(player->getUserInfo()->name())); } + // A tournament with fewer than two players can't produce a valid bracket. + if (tournament->getPlayerCount() < 2) { + qCWarning() << "Cannot start tournament with fewer than 2 players"; + return; + } + tournament->startTournament(); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 3b8d794dc..3467ed10c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -111,7 +112,7 @@ private: bool isTournament; TournamentSettings tournamentSettings; Server_Tournament *tournament; - Server_Game *tournamentParentGame; + QPointer tournamentParentGame; int tournamentMatchPlayer1Id; int tournamentMatchPlayer2Id; bool disconnectRemovesPlayer; @@ -271,7 +272,7 @@ public: void startTournament(); void setPlayerTournamentDeck(int playerId, DeckList *deck); void setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id); - Server_Game *getTournamentParentGame() const + QPointer getTournamentParentGame() const { return tournamentParentGame; } @@ -279,6 +280,10 @@ public: { return disconnectRemovesPlayer; } + void setDisconnectRemovesPlayer(bool _disconnectRemovesPlayer) + { + disconnectRemovesPlayer = _disconnectRemovesPlayer; + } // Server_MatchGameFactory implementation Server_Game *createMatchGame(const GameConfig &config, int &outGameId) override; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp index 1d13f31f7..527f2e728 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -31,9 +31,11 @@ Server_Tournament::~Server_Tournament() void Server_Tournament::addPlayer(int playerId, const QString &playerName) { + QMutexLocker locker(&tournamentMutex); TournamentPlayerData data; data.playerId = playerId; data.playerName = playerName; + data.dropped = false; players[playerId] = data; } @@ -51,6 +53,41 @@ void Server_Tournament::removePlayer(int playerId) { QMutexLocker locker(&tournamentMutex); players.remove(playerId); + submittedDecks.remove(playerId); + byeGivenPlayers.remove(playerId); +} + +void Server_Tournament::dropPlayer(int playerId) +{ + QMutexLocker locker(&tournamentMutex); + if (!players.contains(playerId)) { + return; + } + players[playerId].dropped = true; + players[playerId].deckSubmitted = false; + + // Any current pairing that involves the dropped player and is not already + // decided is awarded to the surviving opponent (or recorded as undecided if + // both dropped). The opponent keeps playing without sitting out a round. + for (auto &pairing : currentPairings) { + if (pairing.winnerId != -2) { + continue; + } + bool involvesDropped = (pairing.player1Id == playerId || pairing.player2Id == playerId); + if (!involvesDropped) { + continue; + } + if (pairing.player1Id == playerId && pairing.player2Id == playerId) { + continue; + } + int opponent = (pairing.player1Id == playerId) ? pairing.player2Id : pairing.player1Id; + if (players.contains(opponent) && !players[opponent].dropped) { + pairing.winnerId = opponent; + players[opponent].wins += 1; + players[playerId].losses += 1; + } + allPreviousPairings.append(qMakePair(pairing.player1Id, pairing.player2Id)); + } } void Server_Tournament::startTournament() @@ -97,7 +134,9 @@ void Server_Tournament::generateSwissPairings() currentPairings.clear(); QList available; for (auto it = players.constBegin(); it != players.constEnd(); ++it) { - available.append(it->playerId); + if (!it->dropped) { + available.append(it->playerId); + } } // Sort by wins descending (and by record for tie-breaking) @@ -113,8 +152,10 @@ void Server_Tournament::generateSwissPairings() return a < b; }); - // Simple greedy Swiss pairing QSet paired; + // Try to pair every player, allowing a single rematch only if the greedy pass + // would otherwise leave any unpaired remainder. Dropped players are never paired. + int maxRematches = available.size() / 2; for (int i = 0; i < available.size(); ++i) { if (paired.contains(available[i])) { continue; @@ -123,38 +164,74 @@ void Server_Tournament::generateSwissPairings() if (paired.contains(available[j])) { continue; } - if (!havePlayed(available[i], available[j])) { - TournamentPairingData pairing; - pairing.player1Id = available[i]; - pairing.player2Id = available[j]; - currentPairings.append(pairing); - paired.insert(available[i]); - paired.insert(available[j]); - break; + bool rematch = havePlayed(available[i], available[j]); + if (rematch && maxRematches <= 0) { + continue; } + TournamentPairingData pairing; + pairing.player1Id = available[i]; + pairing.player2Id = available[j]; + currentPairings.append(pairing); + paired.insert(available[i]); + paired.insert(available[j]); + if (rematch) { + --maxRematches; + } + break; } } - // Bye for unpaired player if odd count - for (int i = 0; i < available.size(); ++i) { - if (!paired.contains(available[i])) { - // Player gets a bye (auto-win) - TournamentPairingData bye; - bye.player1Id = available[i]; - bye.player2Id = -1; - bye.winnerId = available[i]; - bye.player1MatchWins = gamesPerMatch; // Match immediately decided - currentPairings.append(bye); - players[available[i]].wins += 1; - allPreviousPairings.append(qMakePair(available[i], -1)); - break; + // Give a bye to every remaining unpaired eligible player, worst-ranked first. + // A player receives at most one bye over the whole tournament. + QList unpaired; + for (int id : available) { + if (!paired.contains(id)) { + unpaired.append(id); } } + // Byes go to the lowest-ranked eligible player who has not had one yet. + std::sort(unpaired.begin(), unpaired.end(), [this](int a, int b) { + const auto &pa = players[a]; + const auto &pb = players[b]; + if (pa.wins != pb.wins) { + return pa.wins < pb.wins; + } + if (pa.losses != pb.losses) { + return pa.losses > pb.losses; + } + return a > b; + }); + + for (int id : unpaired) { + if (byeGivenPlayers.contains(id)) { + // Already used a bye: a dropped opponent or earlier bye means this player + // simply sits out the round with a free win to keep the bracket moving. + TournamentPairingData bye; + bye.player1Id = id; + bye.player2Id = -1; + bye.winnerId = id; + currentPairings.append(bye); + continue; + } + TournamentPairingData bye; + bye.player1Id = id; + bye.player2Id = -1; + bye.winnerId = id; + currentPairings.append(bye); + players[id].wins += 1; + byeGivenPlayers.insert(id); + allPreviousPairings.append(qMakePair(id, -1)); + } } int Server_Tournament::calculateTotalRounds() const { - int n = players.size(); + int n = 0; + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (!it->dropped) { + ++n; + } + } if (n <= 1) { return 0; } @@ -208,10 +285,16 @@ void Server_Tournament::enqueueMatchGameCreation() { QMutexLocker locker(&tournamentMutex); for (const auto &pairing : currentPairings) { - if (pairing.player2Id != -1 && pairing.winnerId == -2 && - pairing.matchGameIds.size() < static_cast(gamesPerMatch)) { - planned.append(qMakePair(pairing.player1Id, pairing.player2Id)); + if (pairing.player2Id == -1 || pairing.winnerId != -2) { + continue; } + if (players.value(pairing.player1Id).dropped || players.value(pairing.player2Id).dropped) { + continue; + } + if (pairing.matchGameIds.size() >= static_cast(gamesPerMatch)) { + continue; + } + planned.append(qMakePair(pairing.player1Id, pairing.player2Id)); } } if (planned.isEmpty()) { @@ -277,12 +360,25 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) return; } } + + // Bail out if either participant is no longer connected: a match game with + // zero or one connected player can never finish and would stall the round. + if (!matchGameFactory->getUserInterface(player1Name) || !matchGameFactory->getUserInterface(player2Name)) { + qCWarning(TournamentLog) << "Skipping match creation: a player in pairing" << player1Id << player2Id + << "is no longer connected"; + return; + } } - // Create a sub-game for this match via the factory + // Create a sub-game for this match via the factory, copying the real + // ServerInfo_User so it ships the true user level rather than a fabricated + // admin identity that would surface in buddy/ignore-list checks. ServerInfo_User creatorInfo; - creatorInfo.set_name(player1Name.toStdString()); - creatorInfo.set_user_level(ServerInfo_User::IsAdmin | ServerInfo_User::IsRegistered); + if (auto *ui = matchGameFactory->getUserInterface(player1Name)) { + creatorInfo = *ui->getUserInfo(); + } else { + creatorInfo.set_name(player1Name.toStdString()); + } QString gameDesc = gamesPerMatch > 1 ? QString("R%1 Match - Game %2 of %3").arg(round).arg(gameNumber).arg(gamesPerMatch) @@ -302,6 +398,9 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) matchGame->setTournamentMatchInfo(parentGame, player1Id, player2Id); matchGame->setMatchResultStrategy(new Server_TournamentMatchResultStrategy); + // A disconnect inside a tournament match must remove the player so the match + // can be decided; it must not leave them sitting as a half-present participant. + matchGame->setDisconnectRemovesPlayer(true); matchGameFactory->addGameToRoom(matchGame); // Store the game ID in the pairing @@ -317,6 +416,9 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) } // Auto-join both players, sending the join event directly through their UIs. + // Both UI lookups were verified above, so a player can only drop between that + // check and this add — in which case they get handled by drop processing and + // the pairing settles on the surviving opponent. QMap> joiners; auto joinAndSetupPlayer = [&](int pid, const QString &name) { @@ -338,7 +440,8 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) } joiners.clear(); - // Set decks and mark players as ready in the match game + // Set decks and mark players as ready in the match game. + bool anyDeckMissing = false; auto matchPlayers = matchGame->getPlayers(); for (auto *matchPlayer : matchPlayers) { const QString name = QString::fromStdString(matchPlayer->getUserInfo()->name()); @@ -352,11 +455,21 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) if (!deckNative.isEmpty()) { matchPlayer->setDeck(new DeckList(deckNative)); matchPlayer->setReadyStart(true); + } else { + anyDeckMissing = true; } } - // Start the match game - matchGame->startGameIfReady(true); + if (anyDeckMissing) { + // Not every participant submitted a deck. Do not force-start: that would + // kick the players without a deck. Leave the match game open so they can + // select a deck; the host starts it through the normal ready flow. + return; + } + + // Start the match game without forcing: both participants are ready and have + // decks, so there is nothing to kick. + matchGame->startGameIfReady(false); } void Server_Tournament::recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges) @@ -440,20 +553,29 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game } else if (winnerId == pairingPtr->player2Id) { pairingPtr->player2MatchWins += 1; } - // Draw (winnerId == -1): no match wins incremented + // Draw (winnerId == -1): counts nothing toward the series but does consume + // a slot, so a series can still end in a draw when it is exhausted. - // Check if match is decided - const int gamesNeeded = static_cast(gamesPerMatch); + // The winner needs a strict majority of the games in the series. + const int gamesPlayed = pairingPtr->matchGameIds.size(); + const int gamesNeeded = static_cast(gamesPerMatch / 2 + 1); + const int gamesRemaining = static_cast(gamesPerMatch) - gamesPlayed; matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded); + if (!matchDecided) { + // Series exhausted without a strict-majority winner (e.g. a drawn Bo3 + // leaves it 1-1): record the match as a draw so the round always advances. + matchDecided = (gamesRemaining <= 0) && (pairingPtr->player1MatchWins == pairingPtr->player2MatchWins); + } if (matchDecided) { // Determine match winner - int matchWinnerId; + int matchWinnerId = -1; if (pairingPtr->player1MatchWins >= gamesNeeded) { matchWinnerId = pairingPtr->player1Id; - } else { + } else if (pairingPtr->player2MatchWins >= gamesNeeded) { matchWinnerId = pairingPtr->player2Id; } + // Otherwise the series was exhausted evenly — matchWinnerId stays -1 (a draw). // Set the match winner on the pairing pairingPtr->winnerId = matchWinnerId; @@ -462,9 +584,12 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game if (matchWinnerId == pairingPtr->player1Id) { players[pairingPtr->player1Id].wins += 1; players[pairingPtr->player2Id].losses += 1; - } else { + } else if (matchWinnerId == pairingPtr->player2Id) { players[pairingPtr->player2Id].wins += 1; players[pairingPtr->player1Id].losses += 1; + } else { + players[pairingPtr->player1Id].draws += 1; + players[pairingPtr->player2Id].draws += 1; } // Store for future pairing avoidance @@ -546,8 +671,13 @@ void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) p->set_player1_id(pairing.player1Id); p->set_player2_id(pairing.player2Id); p->set_game_id(pairing.gameId); - // Map internal sentinel: -2 (undecided) -> -1 (no winner yet in proto) - p->set_winner_id(pairing.winnerId == -2 ? -1 : pairing.winnerId); + // -2 = undecided; a decided draw is -1. The is_draw bit distinguishes a + // reported draw from an unset winner_id on the wire. + if (pairing.winnerId == -1) { + p->set_is_draw(true); + } else if (pairing.winnerId != -2) { + p->set_winner_id(pairing.winnerId); + } p->set_player1_match_wins(pairing.player1MatchWins); p->set_player2_match_wins(pairing.player2MatchWins); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h index b59edda4f..aeb3ea732 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,9 @@ public: void addPlayer(int playerId, const QString &playerName); void removePlayer(int playerId); + // Marks an already-starting/started tournament player as dropped: they stop + // being paired and their outstanding unstarted match is awarded as a loss. + void dropPlayer(int playerId); void startTournament(); void advanceRound(GameEventStorage &ges); void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges); @@ -68,6 +72,7 @@ public: int losses = 0; int draws = 0; bool deckSubmitted = false; + bool dropped = false; }; struct TournamentPairingData @@ -82,12 +87,15 @@ public: }; private: - Server_Game *parentGame; + QPointer parentGame; Server_MatchGameFactory *matchGameFactory; mutable QRecursiveMutex tournamentMutex; QMap players; QMap submittedDecks; QList currentPairings; + // Players that have already received a bye in a previous round, so no one + // gets more than one bye over the whole tournament. + QSet byeGivenPlayers; QList> allPreviousPairings; int currentRound; int totalRounds; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp index 5149b9a26..73035dd94 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp @@ -11,7 +11,7 @@ Server_GameLifecycleStrategy::StartAction Server_TournamentLifecycleStrategy::on { // Match sub-games start through the normal flow; only the tournament hub game is // managed by this lifecycle. - if (game->getTournamentParentGame() != nullptr) { + if (game->getTournamentParentGame().data() != nullptr) { return StartAction::ProceedNormal; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp index 5611224e5..7ca8aacc9 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp @@ -12,7 +12,10 @@ bool Server_TournamentMatchResultStrategy::onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) { - auto *parentGame = game->getTournamentParentGame(); + // The hub game is owned by the room and may be torn down once its host leaves + // and no players remain, while the match sub-games keep running. QPointer keeps + // this link checked so a later-finishing match can't touch freed memory. + auto *parentGame = game->getTournamentParentGame().data(); if (!parentGame || !parentGame->getTournament()) { return false; } From f25990ff034dfab73863bd0858d5f00c8c5df880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 09:47:27 +0200 Subject: [PATCH 03/14] [Server] Adapt tournament engine to the shared settings message - read games per match from Command_CreateGame.tournament_settings after the proto rework replaced the standalone games_per_match field - use qWarning to match the surrounding file --- .../libcockatrice/network/server/remote/game/server_game.cpp | 2 +- .../network/server/remote/server_protocolhandler.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index f536dd3ca..775372ec5 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -1063,7 +1063,7 @@ void Server_Game::startTournament() // A tournament with fewer than two players can't produce a valid bracket. if (tournament->getPlayerCount() < 2) { - qCWarning() << "Cannot start tournament with fewer than 2 players"; + qWarning() << "Cannot start tournament with fewer than 2 players"; return; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index eacb9802c..375d73e0d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1097,7 +1097,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false; bool isTournament = cmd.has_is_tournament() ? cmd.is_tournament() : false; - int gamesPerMatch = cmd.has_games_per_match() ? static_cast(cmd.games_per_match()) : 1; + int gamesPerMatch = + cmd.has_tournament_settings() ? static_cast(cmd.tournament_settings().games_per_match()) : 1; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { From 5c25f33ca8fba95c1bd12c8556697701333f8b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 09:49:53 +0200 Subject: [PATCH 04/14] [Server] Replay tournament state to late joiners The bracket, phase and standings live in Event_TournamentState, which only flows on mutation. Without a copy a player or spectator joining after round one would sit on an empty bracket until the next advance, so the current state is now enqueued as part of the join snapshot. Extracts Event_TournamentState building into buildStateEvent() so both the broadcast path and the join path share one source of truth. --- .../network/server/remote/game/server_game.cpp | 9 +++++++++ .../network/server/remote/game/server_tournament.cpp | 9 +++++++-- .../network/server/remote/game/server_tournament.h | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 775372ec5..65b439f93 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -898,6 +898,15 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti prepareGameEvent(event, deckOwner->getPlayerId(), &context)); } } + + // A tournament's bracket/phase/standings live in Event_TournamentState, which + // normally only flows on mutation. Without a copy here a late joiner would sit + // on an empty bracket until the next round advances, so replay the current + // state as part of the join snapshot. + if (tournament) { + rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, + prepareGameEvent(tournament->buildStateEvent(), -1)); + } } void Server_Game::sendGameEventContainer(GameEventContainer *cont, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp index 527f2e728..65529072f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -635,7 +635,7 @@ void Server_Tournament::checkAndAdvanceRound(GameEventStorage &ges) } } -void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) +Event_TournamentState Server_Tournament::buildStateEvent() const { QMutexLocker locker(&tournamentMutex); @@ -682,5 +682,10 @@ void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) p->set_player2_match_wins(pairing.player2MatchWins); } - ges.enqueueGameEvent(state, -1); + return state; +} + +void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) +{ + ges.enqueueGameEvent(buildStateEvent(), -1); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h index aeb3ea732..438490e84 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -36,6 +36,8 @@ public: void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges); bool recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges); void broadcastTournamentState(GameEventStorage &ges); + // Current tournament state message, for replaying to a participant joining late. + Event_TournamentState buildStateEvent() const; bool isStarted() const { From 1c6af5eaba1fe5b25aaa55d896d1aed346bc0936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 10:28:12 +0200 Subject: [PATCH 05/14] [Server] Stop mirroring is_tournament on the game-state event The client learns tournament status from Event_GameJoined's ServerInfo_Game; mirroring it on every Event_GameStateChanged was dead wire data. The field was removed from the proto in [Protocol]; drop the now-invalidated setter. --- .../libcockatrice/network/server/remote/game/server_game.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 65b439f93..008af02d2 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -310,8 +310,6 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } - event->set_is_tournament(isTournament); - if (tournamentParentGame) { event->set_parent_game_id(tournamentParentGame->getGameId()); } From 84a8b751d34a66e57f13142f7c964289b9d6440d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 14:58:06 +0200 Subject: [PATCH 06/14] [Tournament] Fix GameConfig forward declaration and annotate command handlers Forward-declare GameConfig as struct to match its definition in game_config.h; the class/struct mismatch trips -Werror -Wmismatched-tags on macOS. Also add the missing /// Server: annotations on the tournament GameCommandType entries now that the Server_AbstractParticipant handlers they point at exist in the tree. --- .../network/server/remote/game/server_match_game_factory.h | 2 +- .../libcockatrice/protocol/pb/game_commands.proto | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h index d5cf81cb6..3963b99d2 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h @@ -3,7 +3,7 @@ #include -class GameConfig; +struct GameConfig; class Server_Game; class Server_AbstractUserInterface; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 68a79eb6b..21560278c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -182,12 +182,18 @@ message GameCommand { SET_PLAYMAT = 1035; /// Report the result of a tournament match sub-game. + /// Server: Server_AbstractParticipant::cmdReportMatchResult + /// Client: reflected via tournament state events REPORT_MATCH_RESULT = 1037; /// Advance the tournament to the next round. + /// Server: Server_AbstractParticipant::cmdAdvanceTournament + /// Client: reflected via tournament state events ADVANCE_TOURNAMENT = 1038; /// Select tournament settings. + /// Server: Server_AbstractParticipant::cmdTournamentSettingsSelect + /// Client: reflected via tournament state events TOURNAMENT_SETTINGS_SELECT = 1039; } From d2c93aee7bbff55e0a660808ceac1eabbc9e504b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 17:37:30 +0200 Subject: [PATCH 07/14] [Server] Fix tournament stuck-round and sub-game start paths Gate the host-only start on the hub object so match sub-games can start, advance the round when a drop decides the last pairing (and on late results), settle disconnected pairings instead of stalling, and decide exhausted series by game wins. --- .../server/remote/game/server_game.cpp | 5 +- .../server/remote/game/server_tournament.cpp | 219 +++++++++++------- .../server/remote/game/server_tournament.h | 2 +- 3 files changed, 138 insertions(+), 88 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 008af02d2..46dd84db7 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -369,7 +369,8 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) // Tournament hubs must be host-started and can't lock in a partially filled // bracket: a mere "everyone current is ready" must not start a 1-player or // undersized tournament. startTournament() additionally enforces 2+ players. - if (isTournament && !forceStartGame) { + // Match sub-games carry isTournament too, so gate on the hub object only. + if (tournament && !forceStartGame) { return; } @@ -633,7 +634,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve if (tournament && !tournamentParentGame && !spectator) { const int leavingPlayerId = participant->getPlayerId(); GameEventStorage tournGes; - tournament->dropPlayer(leavingPlayerId); + tournament->dropPlayer(leavingPlayerId, tournGes); tournament->broadcastTournamentState(tournGes); tournGes.sendToGame(this); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp index 65529072f..f8345c96d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -57,37 +57,41 @@ void Server_Tournament::removePlayer(int playerId) byeGivenPlayers.remove(playerId); } -void Server_Tournament::dropPlayer(int playerId) +void Server_Tournament::dropPlayer(int playerId, GameEventStorage &ges) { - QMutexLocker locker(&tournamentMutex); - if (!players.contains(playerId)) { - return; - } - players[playerId].dropped = true; - players[playerId].deckSubmitted = false; + { + QMutexLocker locker(&tournamentMutex); + if (!players.contains(playerId)) { + return; + } + players[playerId].dropped = true; + players[playerId].deckSubmitted = false; - // Any current pairing that involves the dropped player and is not already - // decided is awarded to the surviving opponent (or recorded as undecided if - // both dropped). The opponent keeps playing without sitting out a round. - for (auto &pairing : currentPairings) { - if (pairing.winnerId != -2) { - continue; + // Any current pairing that involves the dropped player and is not already + // decided is awarded to the surviving opponent (or recorded as undecided if + // both dropped). The opponent keeps playing without sitting out a round. + for (auto &pairing : currentPairings) { + if (pairing.winnerId != -2) { + continue; + } + bool involvesDropped = (pairing.player1Id == playerId || pairing.player2Id == playerId); + if (!involvesDropped) { + continue; + } + if (pairing.player1Id == playerId && pairing.player2Id == playerId) { + continue; + } + int opponent = (pairing.player1Id == playerId) ? pairing.player2Id : pairing.player1Id; + if (players.contains(opponent) && !players[opponent].dropped) { + pairing.winnerId = opponent; + players[opponent].wins += 1; + players[playerId].losses += 1; + } + allPreviousPairings.append(qMakePair(pairing.player1Id, pairing.player2Id)); } - bool involvesDropped = (pairing.player1Id == playerId || pairing.player2Id == playerId); - if (!involvesDropped) { - continue; - } - if (pairing.player1Id == playerId && pairing.player2Id == playerId) { - continue; - } - int opponent = (pairing.player1Id == playerId) ? pairing.player2Id : pairing.player1Id; - if (players.contains(opponent) && !players[opponent].dropped) { - pairing.winnerId = opponent; - players[opponent].wins += 1; - players[playerId].losses += 1; - } - allPreviousPairings.append(qMakePair(pairing.player1Id, pairing.player2Id)); } + // A drop can decide the last open pairing of the round; advance when complete. + checkAndAdvanceRound(ges); } void Server_Tournament::startTournament() @@ -332,6 +336,7 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) QString deck2Native; int round = 0; int gameNumber = 1; + bool settledWithoutGame = false; { QMutexLocker locker(&tournamentMutex); player1Name = players.value(player1Id).playerName; @@ -363,13 +368,44 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) // Bail out if either participant is no longer connected: a match game with // zero or one connected player can never finish and would stall the round. - if (!matchGameFactory->getUserInterface(player1Name) || !matchGameFactory->getUserInterface(player2Name)) { + // Settle the pairing like dropPlayer does so the round can still advance. + auto *ui1 = matchGameFactory->getUserInterface(player1Name); + auto *ui2 = matchGameFactory->getUserInterface(player2Name); + if (!ui1 || !ui2) { qCWarning(TournamentLog) << "Skipping match creation: a player in pairing" << player1Id << player2Id << "is no longer connected"; - return; + for (auto &pairing : currentPairings) { + if (pairing.player1Id == player1Id && pairing.player2Id == player2Id && pairing.winnerId == -2) { + if (ui1 && !ui2) { + pairing.winnerId = player1Id; + players[player1Id].wins += 1; + players[player2Id].losses += 1; + } else if (ui2 && !ui1) { + pairing.winnerId = player2Id; + players[player2Id].wins += 1; + players[player1Id].losses += 1; + } else { + pairing.winnerId = -1; + players[player1Id].draws += 1; + players[player2Id].draws += 1; + } + allPreviousPairings.append(qMakePair(player1Id, player2Id)); + break; + } + } + settledWithoutGame = true; } } + if (settledWithoutGame) { + GameEventStorage ges; + broadcastTournamentState(ges); + checkAndAdvanceRound(ges); + broadcastTournamentState(ges); + ges.sendToGame(parentGame); + return; + } + // Create a sub-game for this match via the factory, copying the real // ServerInfo_User so it ships the true user level rather than a fabricated // admin identity that would surface in buddy/ignore-list checks. @@ -543,65 +579,78 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game } // If the match is already decided, ignore further sub-game results + // but still check whether the round can advance (e.g. a drop decided + // the last pairing and this late result is the first chance to notice). if (pairingPtr->winnerId != -2) { - return true; - } - - // Increment per-match wins - if (winnerId == pairingPtr->player1Id) { - pairingPtr->player1MatchWins += 1; - } else if (winnerId == pairingPtr->player2Id) { - pairingPtr->player2MatchWins += 1; - } - // Draw (winnerId == -1): counts nothing toward the series but does consume - // a slot, so a series can still end in a draw when it is exhausted. - - // The winner needs a strict majority of the games in the series. - const int gamesPlayed = pairingPtr->matchGameIds.size(); - const int gamesNeeded = static_cast(gamesPerMatch / 2 + 1); - const int gamesRemaining = static_cast(gamesPerMatch) - gamesPlayed; - matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded); - if (!matchDecided) { - // Series exhausted without a strict-majority winner (e.g. a drawn Bo3 - // leaves it 1-1): record the match as a draw so the round always advances. - matchDecided = (gamesRemaining <= 0) && (pairingPtr->player1MatchWins == pairingPtr->player2MatchWins); - } - - if (matchDecided) { - // Determine match winner - int matchWinnerId = -1; - if (pairingPtr->player1MatchWins >= gamesNeeded) { - matchWinnerId = pairingPtr->player1Id; - } else if (pairingPtr->player2MatchWins >= gamesNeeded) { - matchWinnerId = pairingPtr->player2Id; - } - // Otherwise the series was exhausted evenly — matchWinnerId stays -1 (a draw). - - // Set the match winner on the pairing - pairingPtr->winnerId = matchWinnerId; - - // Update tournament-level player records - if (matchWinnerId == pairingPtr->player1Id) { - players[pairingPtr->player1Id].wins += 1; - players[pairingPtr->player2Id].losses += 1; - } else if (matchWinnerId == pairingPtr->player2Id) { - players[pairingPtr->player2Id].wins += 1; - players[pairingPtr->player1Id].losses += 1; - } else { - players[pairingPtr->player1Id].draws += 1; - players[pairingPtr->player2Id].draws += 1; - } - - // Store for future pairing avoidance - allPreviousPairings.append(qMakePair(pairingPtr->player1Id, pairingPtr->player2Id)); + broadcastTournamentState(ges); + // Fall through to checkAndAdvanceRound below. + matchDecided = true; + seriesContinues = false; } else { - // Match not decided — spawn the next sub-game outside all locks - seriesContinues = true; - p1 = pairingPtr->player1Id; - p2 = pairingPtr->player2Id; - } + // Increment per-match wins + if (winnerId == pairingPtr->player1Id) { + pairingPtr->player1MatchWins += 1; + } else if (winnerId == pairingPtr->player2Id) { + pairingPtr->player2MatchWins += 1; + } + // Draw (winnerId == -1): counts nothing toward the series but does consume + // a slot, so a series can still end in a draw when it is exhausted. - broadcastTournamentState(ges); + // The winner needs a strict majority of the games in the series. + const int gamesPlayed = pairingPtr->matchGameIds.size(); + const int gamesNeeded = static_cast(gamesPerMatch / 2 + 1); + const int gamesRemaining = static_cast(gamesPerMatch) - gamesPlayed; + matchDecided = + (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded); + if (!matchDecided && gamesRemaining <= 0) { + // Series exhausted: draws can leave the wins unequal without either + // side reaching a majority (e.g. Bo3 going win/draw/draw for 1-0). + // Decide by game wins, draw only on a tie, so the round advances. + matchDecided = true; + } + + if (matchDecided) { + // Determine match winner + int matchWinnerId = -1; + if (pairingPtr->player1MatchWins >= gamesNeeded) { + matchWinnerId = pairingPtr->player1Id; + } else if (pairingPtr->player2MatchWins >= gamesNeeded) { + matchWinnerId = pairingPtr->player2Id; + } else if (gamesRemaining <= 0) { + if (pairingPtr->player1MatchWins > pairingPtr->player2MatchWins) { + matchWinnerId = pairingPtr->player1Id; + } else if (pairingPtr->player2MatchWins > pairingPtr->player1MatchWins) { + matchWinnerId = pairingPtr->player2Id; + } + } + // Otherwise the series was exhausted evenly — matchWinnerId stays -1 (a draw). + + // Set the match winner on the pairing + pairingPtr->winnerId = matchWinnerId; + + // Update tournament-level player records + if (matchWinnerId == pairingPtr->player1Id) { + players[pairingPtr->player1Id].wins += 1; + players[pairingPtr->player2Id].losses += 1; + } else if (matchWinnerId == pairingPtr->player2Id) { + players[pairingPtr->player2Id].wins += 1; + players[pairingPtr->player1Id].losses += 1; + } else { + players[pairingPtr->player1Id].draws += 1; + players[pairingPtr->player2Id].draws += 1; + } + + // Store for future pairing avoidance + allPreviousPairings.append(qMakePair(pairingPtr->player1Id, pairingPtr->player2Id)); + } else { + // Match not decided — spawn the next sub-game outside all locks + seriesContinues = true; + p1 = pairingPtr->player1Id; + p2 = pairingPtr->player2Id; + } + + broadcastTournamentState(ges); + } // else (undecided pairing) } if (seriesContinues) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h index 438490e84..3d976da7b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -30,7 +30,7 @@ public: void removePlayer(int playerId); // Marks an already-starting/started tournament player as dropped: they stop // being paired and their outstanding unstarted match is awarded as a loss. - void dropPlayer(int playerId); + void dropPlayer(int playerId, GameEventStorage &ges); void startTournament(); void advanceRound(GameEventStorage &ges); void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges); From 36e7f533bcad13953e56217c84c569a8f26f60f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 24 Aug 2026 09:07:11 +0200 Subject: [PATCH 08/14] [Client] Add tournament event plumbing and player extension points Wires the client game layer for tournament state without touching existing behavior: - GameEventHandler dispatches Event_TournamentState (2027) to a new tournamentStateChanged signal - GameMetaInfo exposes isTournament and parentGameId over the new ServerInfo_Game field - PlayerEventHandler::processGameEvent is virtual and player is protected; PlayerLogic gains a protected constructor accepting a custom handler, so mode-specific subclasses can intercept events --- .../libcockatrice/game/game_event_handler.cpp | 10 ++++++++ .../libcockatrice/game/game_event_handler.h | 2 ++ .../libcockatrice/game/game_meta_info.h | 21 ++++++++++++++++ .../game/player/player_event_handler.h | 10 ++++---- .../game/player/player_logic.cpp | 13 ++++++++++ .../libcockatrice/game/player/player_logic.h | 25 ++++++++++++++++++- 6 files changed, 75 insertions(+), 6 deletions(-) diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 856475dfa..27526a361 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -189,6 +190,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont, case GameEvent::REVERSE_TURN: eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context); break; + case GameEvent::TOURNAMENT_STATE: + emit tournamentStateChanged(event.GetExtension(Event_TournamentState::ext)); + break; default: { PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0); @@ -375,6 +379,12 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event int /*eventPlayerId*/, const GameEventContext & /*context*/) { + // Sub-games of a tournament report their parent hub game so the client can + // route "close game" back to the parent tab. + if (event.parent_game_id() != -1) { + game->getGameMetaInfo()->setParentGameId(event.parent_game_id()); + } + const int playerListSize = event.player_list_size(); QVector>> opponentDecksToDisplay; diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h index f7b11780b..1320d053b 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -50,6 +50,7 @@ class Event_SetActivePhase; class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; +class Event_TournamentState; class Event_Ping; class ServerInfo_PlayerProperties; namespace google @@ -415,6 +416,7 @@ signals: void gameStopped(); void gameClosed(); + void tournamentStateChanged(const Event_TournamentState &state); void playerPropertiesChanged(const ServerInfo_PlayerProperties &prop, int playerId); void playerJoined(const ServerInfo_PlayerProperties &playerInfo); void playerLeft(int leavingPlayerId); diff --git a/libcockatrice_game/libcockatrice/game/game_meta_info.h b/libcockatrice_game/libcockatrice/game/game_meta_info.h index ef8f0c08e..0ea5cd7cc 100644 --- a/libcockatrice_game/libcockatrice/game/game_meta_info.h +++ b/libcockatrice_game/libcockatrice/game/game_meta_info.h @@ -87,6 +87,26 @@ public: return roomGameTypes.find(gameInfo_.game_types(index)).value(); } + bool isTournament() const + { + return gameInfo_.is_tournament(); + } + + void setIsTournament(bool t) + { + gameInfo_.set_is_tournament(t); + } + + int parentGameId() const + { + return parentGameId_; + } + + void setParentGameId(int id) + { + parentGameId_ = id; + } + public slots: void setStarted(bool s) { @@ -111,6 +131,7 @@ signals: private: ServerInfo_Game gameInfo_; + int parentGameId_ = -1; }; #endif // GAME_META_INFO_H diff --git a/libcockatrice_game/libcockatrice/game/player/player_event_handler.h b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h index d253deef0..59e448d7a 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h @@ -93,10 +93,10 @@ public: * @param context Additional context (undo, judge, etc.). * @param options Processing options (UI suppression, reveal behavior). */ - void processGameEvent(GameEvent::GameEventType type, - const GameEvent &event, - const GameEventContext &context, - EventProcessingOptions options); + virtual void processGameEvent(GameEvent::GameEventType type, + const GameEvent &event, + const GameEventContext &context, + EventProcessingOptions options); /** @} */ @@ -277,7 +277,7 @@ signals: */ void cardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); -private: +protected: /** Owning player instance. */ PlayerLogic *player; diff --git a/libcockatrice_game/libcockatrice/game/player/player_logic.cpp b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp index 96bd07164..9098729eb 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp @@ -49,6 +49,19 @@ PlayerLogic::PlayerLogic(const ServerInfo_User &info, initializeZones(); } +PlayerLogic::PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + PlayerEventHandler *customEventHandler) + : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), + playerEventHandler(customEventHandler), playerActions(new PlayerActions(this)), active(false), conceded(false), + zoneId(0), dialogSemaphore(false) +{ + initializeZones(); +} + void PlayerLogic::initializeZones() { addZone(new PileZoneLogic(this, ZoneNames::DECK, false, true, false, this)); diff --git a/libcockatrice_game/libcockatrice/game/player/player_logic.h b/libcockatrice_game/libcockatrice/game/player/player_logic.h index d6c4f5187..168d5fd5f 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_logic.h +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.h @@ -103,7 +103,30 @@ public: ICardPreferenceProvider *cardPreferences = nullptr); ~PlayerLogic() override; - void initializeZones(); +protected: + /** + * @brief Constructor for subclasses that need a custom event handler (e.g. DraftPlayerLogic). + * + * @p customEventHandler must be non-null and either QObject-parented to this PlayerLogic + * or deleted externally; it is not owned by PlayerLogic. The handler connects to @c player + * during its own constructor, so passing a freshly built subclass handler from an + * initializer list is safe. + */ + PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + PlayerEventHandler *customEventHandler); + +public: + /** + * @brief Creates the standard zone set. + * + * Not virtually dispatched from constructors — subclasses overriding this must add + * their extra zones in their own constructor body. + */ + virtual void initializeZones(); void updateZones(); void clear(); From dfa0edfcdb85d612f5c7ab833b26b725a3716c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 10:27:44 +0200 Subject: [PATCH 09/14] [Client] Drop dead player event handler extension points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second PlayerLogic constructor taking a custom PlayerEventHandler was UB (converting this to PlayerLogic* in the base initializer, and connecting a freshly-built handler against a not-yet-constructed object), and its abstract extension surface — virtual initializeZones, virtual getPlayerEventHandler, virtual processGameEvent, protected player member — has no subclass consumer in the stack. Revert to the single primary constructor. Also: - Drop the unused GameMetaInfo::setIsTournament setter. Tournament status is authoritative from Event_GameJoined's ServerInfo_Game; the redundant is_tournament field on Event_GameStateChanged is removed in [Protocol]. - Gate the parent-game routing on has_parent_game_id() instead of comparing the proto default, so a future drop of the default keeps working. --- .../libcockatrice/game/game_event_handler.cpp | 5 ++-- .../libcockatrice/game/game_meta_info.h | 5 ---- .../game/player/player_event_handler.h | 10 ++++---- .../game/player/player_logic.cpp | 13 ---------- .../libcockatrice/game/player/player_logic.h | 25 +------------------ 5 files changed, 9 insertions(+), 49 deletions(-) diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 27526a361..931e6425c 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -380,8 +380,9 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event const GameEventContext & /*context*/) { // Sub-games of a tournament report their parent hub game so the client can - // route "close game" back to the parent tab. - if (event.parent_game_id() != -1) { + // route "close game" back to the parent tab. Use has_parent_game_id rather + // than comparing the default so a future drop of the proto default still works. + if (event.has_parent_game_id()) { game->getGameMetaInfo()->setParentGameId(event.parent_game_id()); } diff --git a/libcockatrice_game/libcockatrice/game/game_meta_info.h b/libcockatrice_game/libcockatrice/game/game_meta_info.h index 0ea5cd7cc..b408ef061 100644 --- a/libcockatrice_game/libcockatrice/game/game_meta_info.h +++ b/libcockatrice_game/libcockatrice/game/game_meta_info.h @@ -92,11 +92,6 @@ public: return gameInfo_.is_tournament(); } - void setIsTournament(bool t) - { - gameInfo_.set_is_tournament(t); - } - int parentGameId() const { return parentGameId_; diff --git a/libcockatrice_game/libcockatrice/game/player/player_event_handler.h b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h index 59e448d7a..d253deef0 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h @@ -93,10 +93,10 @@ public: * @param context Additional context (undo, judge, etc.). * @param options Processing options (UI suppression, reveal behavior). */ - virtual void processGameEvent(GameEvent::GameEventType type, - const GameEvent &event, - const GameEventContext &context, - EventProcessingOptions options); + void processGameEvent(GameEvent::GameEventType type, + const GameEvent &event, + const GameEventContext &context, + EventProcessingOptions options); /** @} */ @@ -277,7 +277,7 @@ signals: */ void cardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); -protected: +private: /** Owning player instance. */ PlayerLogic *player; diff --git a/libcockatrice_game/libcockatrice/game/player/player_logic.cpp b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp index 9098729eb..96bd07164 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp @@ -49,19 +49,6 @@ PlayerLogic::PlayerLogic(const ServerInfo_User &info, initializeZones(); } -PlayerLogic::PlayerLogic(const ServerInfo_User &info, - int _id, - bool _local, - bool _judge, - AbstractGame *_parent, - PlayerEventHandler *customEventHandler) - : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), - playerEventHandler(customEventHandler), playerActions(new PlayerActions(this)), active(false), conceded(false), - zoneId(0), dialogSemaphore(false) -{ - initializeZones(); -} - void PlayerLogic::initializeZones() { addZone(new PileZoneLogic(this, ZoneNames::DECK, false, true, false, this)); diff --git a/libcockatrice_game/libcockatrice/game/player/player_logic.h b/libcockatrice_game/libcockatrice/game/player/player_logic.h index 168d5fd5f..d6c4f5187 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_logic.h +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.h @@ -103,30 +103,7 @@ public: ICardPreferenceProvider *cardPreferences = nullptr); ~PlayerLogic() override; -protected: - /** - * @brief Constructor for subclasses that need a custom event handler (e.g. DraftPlayerLogic). - * - * @p customEventHandler must be non-null and either QObject-parented to this PlayerLogic - * or deleted externally; it is not owned by PlayerLogic. The handler connects to @c player - * during its own constructor, so passing a freshly built subclass handler from an - * initializer list is safe. - */ - PlayerLogic(const ServerInfo_User &info, - int _id, - bool _local, - bool _judge, - AbstractGame *_parent, - PlayerEventHandler *customEventHandler); - -public: - /** - * @brief Creates the standard zone set. - * - * Not virtually dispatched from constructors — subclasses overriding this must add - * their extra zones in their own constructor body. - */ - virtual void initializeZones(); + void initializeZones(); void updateZones(); void clear(); From de983ed01b64856a4c4b8a35e9fa4d23b18d86ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 24 Aug 2026 12:04:00 +0200 Subject: [PATCH 10/14] [Client] Add tournament UI for creating and following lobby tournaments Wires the tournament protocol into the client: - DlgCreateGame gains a tournament checkbox with a games-per-match settings sub-dialog; both values are sent with Command_CreateGame and mirrored in the join-info variant of the dialog - TournamentWidget renders live standings (with deck-submission state) and pairings for the local player's perspective, and asks to open the current match game via openMatchGameRequested() - TournamentTabGameExtension attaches an overview page to TabGame's stacked views, mirrors phase and submission progress onto a deck-view status strip, and provides round-trip navigation between game view and standings; hosts get an in-game settings dialog during deck building - Open-match falls back to joining as spectator through TabSupervisor; sub-games now return to their parent tab on close, falling back to the normal leave-game flow when the parent is gone --- cockatrice/CMakeLists.txt | 3 + .../widgets/dialogs/dlg_create_game.cpp | 29 ++- .../widgets/dialogs/dlg_create_game.h | 5 + .../dialogs/dlg_tournament_settings.cpp | 48 ++++ .../widgets/dialogs/dlg_tournament_settings.h | 30 +++ .../widgets/draft/tournament_widget.cpp | 227 ++++++++++++++++++ .../widgets/draft/tournament_widget.h | 56 +++++ .../src/interface/widgets/tabs/tab_game.cpp | 27 +++ .../src/interface/widgets/tabs/tab_game.h | 21 +- .../tabs/tournament_tab_game_extension.cpp | 206 ++++++++++++++++ .../tabs/tournament_tab_game_extension.h | 57 +++++ 11 files changed, 706 insertions(+), 3 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h create mode 100644 cockatrice/src/interface/widgets/draft/tournament_widget.cpp create mode 100644 cockatrice/src/interface/widgets/draft/tournament_widget.h create mode 100644 cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ce7ad5712..10096e621 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -56,6 +56,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp + src/interface/widgets/dialogs/dlg_tournament_settings.cpp src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp @@ -211,6 +212,7 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_state_manager.cpp src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp + src/interface/widgets/draft/tournament_widget.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/banner_widget.cpp @@ -392,6 +394,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_server.cpp src/interface/widgets/tabs/tab_supervisor.cpp src/interface/widgets/tabs/tab_visual_database_display.cpp + src/interface/widgets/tabs/tournament_tab_game_extension.cpp src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 104b695e6..4b2b4b6c2 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/widgets/tabs/tab_room.h" +#include "dlg_tournament_settings.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_commands.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" @@ -116,14 +117,29 @@ void DlgCreateGame::sharedCtor() shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + tournamentCheckBox = new QCheckBox(tr("Tournament mode")); + tournamentCheckBox->setToolTip(tr("All players submit a deck up front and rounds are paired automatically")); + tournamentSettingsButton = new QPushButton(tr("Settings...")); + tournamentSettingsButton->setEnabled(false); + connect(tournamentCheckBox, &QCheckBox::toggled, tournamentSettingsButton, &QPushButton::setEnabled); + connect(tournamentSettingsButton, &QPushButton::clicked, this, [this] { + DlgTournamentSettings dlg(this); + dlg.setCurrentGamesPerMatch(tournamentSettings.gamesPerMatch); + if (dlg.exec() == QDialog::Accepted) { + tournamentSettings = dlg.getResult(); + } + }); + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); auto *gameSetupOptionsLayout = new QGridLayout; gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0); + gameSetupOptionsLayout->addWidget(tournamentCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(tournamentSettingsButton, 2, 1); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0); } else { createGameAsJudgeCheckBox->setChecked(false); createGameAsJudgeCheckBox->setHidden(true); @@ -200,7 +216,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType } DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(0), gameTypes(_gameTypes) + : QDialog(parent), room(nullptr), gameTypes(_gameTypes) { sharedCtor(); @@ -217,6 +233,8 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetEnabled(false); startingLifeTotalEdit->setEnabled(false); shareDecklistsOnLoadCheckBox->setEnabled(false); + tournamentCheckBox->setEnabled(false); + tournamentSettingsButton->setEnabled(false); descriptionEdit->setText(QString::fromStdString(gameInfo.description())); maxPlayersEdit->setValue(gameInfo.max_players()); @@ -227,6 +245,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); + tournamentCheckBox->setChecked(gameInfo.is_tournament()); QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -264,6 +283,8 @@ void DlgCreateGame::actReset() startingLifeTotalEdit->setValue(20); shareDecklistsOnLoadCheckBox->setChecked(false); + tournamentCheckBox->setChecked(false); + tournamentSettings = DlgTournamentSettingsResult{}; createGameAsJudgeCheckBox->setChecked(false); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -294,6 +315,10 @@ void DlgCreateGame::actOK() cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); cmd.set_starting_life_total(startingLifeTotalEdit->value()); cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); + cmd.set_is_tournament(tournamentCheckBox->isChecked()); + if (tournamentCheckBox->isChecked()) { + cmd.set_games_per_match(tournamentSettings.gamesPerMatch); + } auto _gameTypes = QString(); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 71b09997c..8bbe914a5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -7,6 +7,8 @@ #ifndef DLG_CREATEGAME_H #define DLG_CREATEGAME_H +#include "dlg_tournament_settings.h" + #include #include #include @@ -51,6 +53,9 @@ private: QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *tournamentCheckBox; + QPushButton *tournamentSettingsButton; + DlgTournamentSettingsResult tournamentSettings; QDialogButtonBox *buttonBox; QPushButton *clearButton; QCheckBox *rememberGameSettings; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp new file mode 100644 index 000000000..49dbf065a --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp @@ -0,0 +1,48 @@ +#include "dlg_tournament_settings.h" + +#include +#include +#include +#include +#include + +DlgTournamentSettings::DlgTournamentSettings(QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Tournament Settings")); + + auto *mainLayout = new QFormLayout(this); + + gamesPerMatchSpin = new QSpinBox(this); + gamesPerMatchSpin->setRange(1, 5); + gamesPerMatchSpin->setValue(1); + gamesPerMatchSpin->setToolTip(tr("Number of games per match (e.g., 3 for Best of 3)")); + mainLayout->addRow(tr("Games per match:"), gamesPerMatchSpin); + + QLabel *hintLabel = new QLabel(tr("Set to 3 for Best of 3, 5 for Best of 5, etc."), this); + hintLabel->setStyleSheet("color: palette(placeholderText);"); + mainLayout->addRow(QString(), hintLabel); + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTournamentSettings::actOK); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addRow(buttonBox); + + setFixedHeight(sizeHint().height()); +} + +DlgTournamentSettingsResult DlgTournamentSettings::getResult() const +{ + DlgTournamentSettingsResult result; + result.gamesPerMatch = gamesPerMatchSpin->value(); + return result; +} + +void DlgTournamentSettings::setCurrentGamesPerMatch(int n) +{ + gamesPerMatchSpin->setValue(n); +} + +void DlgTournamentSettings::actOK() +{ + accept(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h new file mode 100644 index 000000000..909274ad7 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.h @@ -0,0 +1,30 @@ +#ifndef DLG_TOURNAMENT_SETTINGS_H +#define DLG_TOURNAMENT_SETTINGS_H + +#include + +class QDialogButtonBox; +class QSpinBox; + +struct DlgTournamentSettingsResult +{ + int gamesPerMatch = 1; +}; + +class DlgTournamentSettings : public QDialog +{ + Q_OBJECT +public: + explicit DlgTournamentSettings(QWidget *parent = nullptr); + DlgTournamentSettingsResult getResult() const; + void setCurrentGamesPerMatch(int n); + +private slots: + void actOK(); + +private: + QSpinBox *gamesPerMatchSpin; + QDialogButtonBox *buttonBox; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.cpp b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp new file mode 100644 index 000000000..542341bb2 --- /dev/null +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp @@ -0,0 +1,227 @@ +#include "tournament_widget.h" + +#include +#include +#include +#include +#include + +namespace +{ +const int PLAYER_COLUMN = 0; +const int DECK_COLUMN = 1; +const int WINS_COLUMN = 2; +const int LOSSES_COLUMN = 3; +const int DRAWS_COLUMN = 4; + +const int PLAYER1_COLUMN = 0; +const int SCORE_COLUMN = 1; +const int STATUS_COLUMN = 2; +const int PLAYER2_COLUMN = 3; +} // namespace + +TournamentWidget::TournamentWidget(QWidget *parent) : QWidget(parent) +{ + auto *mainLayout = new QVBoxLayout(this); + + statusLabel = new QLabel(this); + statusLabel->setStyleSheet("font-weight: bold;"); + mainLayout->addWidget(statusLabel); + + roundLabel = new QLabel(this); + mainLayout->addWidget(roundLabel); + + standingsTable = new QTableWidget(this); + standingsTable->setColumnCount(5); + standingsTable->horizontalHeader()->setStretchLastSection(true); + standingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + standingsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + standingsTable->verticalHeader()->setVisible(false); + mainLayout->addWidget(standingsTable); + + pairingsTable = new QTableWidget(this); + pairingsTable->setColumnCount(4); + pairingsTable->horizontalHeader()->setStretchLastSection(true); + pairingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + pairingsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + pairingsTable->verticalHeader()->setVisible(false); + mainLayout->addWidget(pairingsTable); + + openMatchButton = new QPushButton(this); + openMatchButton->setEnabled(false); + connect(openMatchButton, &QPushButton::clicked, this, [this]() { emit openMatchGameRequested(currentGameId); }); + mainLayout->addWidget(openMatchButton); + + retranslateUi(); +} + +void TournamentWidget::retranslateUi() +{ + standingsTable->setHorizontalHeaderLabels({tr("Player"), tr("Deck"), tr("W"), tr("L"), tr("D")}); + pairingsTable->setHorizontalHeaderLabels({tr("Player 1"), tr("Score"), tr("Status"), tr("Player 2")}); + openMatchButton->setText(tr("Open match game")); + + if (lastState.has_phase()) { + updateTournamentState(lastState); + } else { + statusLabel->setText(tr("Tournament")); + roundLabel->setVisible(true); + roundLabel->setText(tr("Waiting for the tournament to start")); + } +} + +void TournamentWidget::setLocalPlayerId(int playerId) +{ + localPlayerId = playerId; +} + +QString TournamentWidget::getPlayerName(const Event_TournamentState &state, int playerId) const +{ + for (int i = 0; i < state.players_size(); ++i) { + if (state.players(i).player_id() == playerId) { + return QString::fromStdString(state.players(i).player_name()); + } + } + return tr("Player %1").arg(playerId); +} + +void TournamentWidget::updateTournamentState(const Event_TournamentState &state) +{ + lastState = state; + + int gamesPerMatch = getGamesPerMatch(state); + QString statusText; + switch (state.phase()) { + case Event_TournamentState::PHASE_DECK_BUILDING: + statusText = gamesPerMatch > 1 ? tr("Tournament - Deck building (Best of %1)").arg(gamesPerMatch * 2 - 1) + : tr("Tournament - Deck building"); + break; + case Event_TournamentState::PHASE_PLAYING: + statusText = gamesPerMatch > 1 ? tr("Tournament - Playing (Best of %1)").arg(gamesPerMatch * 2 - 1) + : tr("Tournament - Playing"); + break; + case Event_TournamentState::PHASE_FINISHED: + statusText = gamesPerMatch > 1 ? tr("Tournament - Finished (Best of %1)").arg(gamesPerMatch * 2 - 1) + : tr("Tournament - Finished"); + break; + default: + statusText = tr("Tournament"); + break; + } + statusLabel->setText(statusText); + + // The server does not report rounds before pairing starts; avoid showing "Round 0 of 0". + bool roundStarted = state.current_round() > 0 || state.total_rounds() > 0; + roundLabel->setVisible(roundStarted); + if (roundStarted) { + roundLabel->setText(tr("Round %1 of %2").arg(state.current_round()).arg(state.total_rounds())); + } + + rebuildTables(state); + resolveCurrentGameId(state); + updateOpenMatchButton(); +} + +void TournamentWidget::updateOpenMatchButton() +{ + bool playingPhase = lastState.phase() == Event_TournamentState::PHASE_PLAYING; + openMatchButton->setVisible(playingPhase); + if (!playingPhase) { + return; + } + + if (hasOwnLivePairing) { + openMatchButton->setEnabled(true); + openMatchButton->setText(tr("Open match game")); + openMatchButton->setToolTip(tr("Switch to your current match game")); + } else if (localPlayerId == -1 && hasAnyLivePairing) { + // Spectators have no pairing of their own but may watch any running match. + openMatchButton->setEnabled(true); + openMatchButton->setText(tr("Spectate live match")); + openMatchButton->setToolTip(tr("Watch a match that is currently running")); + } else { + openMatchButton->setEnabled(false); + openMatchButton->setText(tr("Open match game")); + openMatchButton->setToolTip(tr("No match game is running for you right now")); + } +} + +void TournamentWidget::rebuildTables(const Event_TournamentState &state) +{ + standingsTable->setRowCount(state.players_size()); + for (int i = 0; i < state.players_size(); ++i) { + const auto &player = state.players(i); + + auto *nameItem = new QTableWidgetItem(QString::fromStdString(player.player_name())); + nameItem->setToolTip(QString::fromStdString(player.player_name())); + standingsTable->setItem(i, PLAYER_COLUMN, nameItem); + + auto *deckItem = new QTableWidgetItem(player.deck_submitted() ? tr("Submitted") : tr("Pending")); + deckItem->setToolTip(player.deck_submitted() ? tr("Deck submitted") : tr("Still choosing a deck")); + standingsTable->setItem(i, DECK_COLUMN, deckItem); + + standingsTable->setItem(i, WINS_COLUMN, new QTableWidgetItem(QString::number(player.wins()))); + standingsTable->setItem(i, LOSSES_COLUMN, new QTableWidgetItem(QString::number(player.losses()))); + standingsTable->setItem(i, DRAWS_COLUMN, new QTableWidgetItem(QString::number(player.draws()))); + } + + pairingsTable->setRowCount(state.pairings_size()); + for (int i = 0; i < state.pairings_size(); ++i) { + const auto &pairing = state.pairings(i); + + QString name1 = getPlayerName(state, pairing.player1_id()); + QString name2 = pairing.player2_id() == -1 ? tr("BYE") : getPlayerName(state, pairing.player2_id()); + + QString scoreStr; + if (getGamesPerMatch(state) > 1 && pairing.player2_id() != -1) { + scoreStr = tr("%1 - %2").arg(pairing.player1_match_wins()).arg(pairing.player2_match_wins()); + } + + QString statusStr; + if (pairing.player2_id() == -1) { + statusStr = tr("BYE"); + } else if (pairing.winner_id() != -1) { + statusStr = tr("Finished"); + } else { + statusStr = tr("vs"); + } + + pairingsTable->setItem(i, PLAYER1_COLUMN, new QTableWidgetItem(name1)); + pairingsTable->setItem(i, SCORE_COLUMN, new QTableWidgetItem(scoreStr)); + pairingsTable->setItem(i, STATUS_COLUMN, new QTableWidgetItem(statusStr)); + pairingsTable->setItem(i, PLAYER2_COLUMN, new QTableWidgetItem(name2)); + } +} + +int TournamentWidget::getGamesPerMatch(const Event_TournamentState &state) const +{ + return state.has_settings() ? state.settings().games_per_match() : 1; +} + +void TournamentWidget::resolveCurrentGameId(const Event_TournamentState &state) +{ + currentGameId = -1; + hasOwnLivePairing = false; + hasAnyLivePairing = false; + + for (int i = 0; i < state.pairings_size(); ++i) { + const auto &pairing = state.pairings(i); + if (pairing.game_id() == -1) { + continue; + } + if (!hasOwnLivePairing && localPlayerId != -1 && + (pairing.player1_id() == localPlayerId || pairing.player2_id() == localPlayerId)) { + currentGameId = pairing.game_id(); + hasOwnLivePairing = true; + } + if (!hasAnyLivePairing) { + hasAnyLivePairing = true; + if (!hasOwnLivePairing) { + currentGameId = pairing.game_id(); + } + } + if (hasOwnLivePairing && hasAnyLivePairing) { + return; + } + } +} diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.h b/cockatrice/src/interface/widgets/draft/tournament_widget.h new file mode 100644 index 000000000..6bb656d6c --- /dev/null +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.h @@ -0,0 +1,56 @@ +#ifndef TOURNAMENT_WIDGET_H +#define TOURNAMENT_WIDGET_H + +#include +#include + +class QLabel; +class QPushButton; +class QTableWidget; +class QTableWidgetItem; + +/** + * @class TournamentWidget + * @ingroup GameViews + * @brief Displays live standings and pairings for a tournament game. + * + * Updated exclusively via updateTournamentState(); the widget holds no + * logic of its own beyond mapping protocol state to table rows. Navigation + * decisions (which pairing belongs to the local player) are resolved here so + * callers only need to react to openMatchGameRequested(). + */ +class TournamentWidget : public QWidget +{ + Q_OBJECT +public: + explicit TournamentWidget(QWidget *parent = nullptr); + + void updateTournamentState(const Event_TournamentState &state); + void setLocalPlayerId(int playerId); + void retranslateUi(); + +signals: + /*! Emitted when the user asks to open the match game of their current pairing. */ + void openMatchGameRequested(int gameId); + +private: + void rebuildTables(const Event_TournamentState &state); + void resolveCurrentGameId(const Event_TournamentState &state); + void updateOpenMatchButton(); + [[nodiscard]] QString getPlayerName(const Event_TournamentState &state, int playerId) const; + [[nodiscard]] int getGamesPerMatch(const Event_TournamentState &state) const; + + QLabel *statusLabel; + QLabel *roundLabel; + QTableWidget *standingsTable; + QTableWidget *pairingsTable; + QPushButton *openMatchButton; + + int localPlayerId = -1; + int currentGameId = -1; + bool hasOwnLivePairing = false; + bool hasAnyLivePairing = false; + Event_TournamentState lastState; +}; + +#endif // TOURNAMENT_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9e724727f..e35b41480 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -39,6 +39,7 @@ #include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" #include "libcockatrice/utility/card_ref.h" #include "tab_supervisor.h" +#include "tournament_tab_game_extension.h" #include #include @@ -168,6 +169,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, createMenuItems(); createViewMenuItems(); + if (game->getGameMetaInfo()->isTournament()) { + tournamentExtension = new TournamentTabGameExtension(this); + } + connectToGameState(); connectToPlayerManager(); connectToGameEventHandler(); @@ -184,6 +189,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, gameTypes.append(game->getGameMetaInfo()->findRoomGameType(i)); } + if (tournamentExtension) { + tournamentExtension->initializeTournamentMode(); + } + QTimer::singleShot(0, this, &TabGame::loadLayout); } @@ -441,6 +450,10 @@ void TabGame::retranslateUi() } scene->retranslateUi(); + + if (tournamentExtension) { + tournamentExtension->retranslateUi(); + } } void TabGame::refreshShortcuts() @@ -968,8 +981,22 @@ void TabGame::stopGame() } } +bool TabGame::switchToGameTab(int gameId) +{ + return tabSupervisor->switchToGameTabIfAlreadyExists(gameId); +} + void TabGame::closeGame() { + int parentId = game->getGameMetaInfo()->parentGameId(); + if (parentId >= 0) { + if (switchToGameTab(parentId)) { + close(); + } + // If the parent tab is gone, fall through to the normal leave-game + // flow instead of stranding the user on a dead sub-game tab. + } + gameMenu->clear(); gameMenu->addAction(aLeaveGame); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 7bc52202f..bb866edab 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -15,9 +15,11 @@ #include #include #include +#include #include #include #include +#include #include #include // IWYU pragma: keep @@ -34,12 +36,12 @@ class MessageLogWidget; class PlayerLogic; class QAction; class QCompleter; -class QStringListModel; class QWidget; class ServerInfo_Player; class ServerInfo_User; class TabSupervisor; class TearOffMenu; +class TournamentTabGameExtension; struct CardRef; struct LoadedDeck; @@ -100,6 +102,8 @@ private: QList phaseActions; QAction *aCardMenu; + QPointer tournamentExtension; + /** * @brief The actions associated with managing a QDockWidget */ @@ -197,6 +201,8 @@ public: void connectToGameEventHandler(); void connectMessageLogToGameEventHandler(); void connectPlayerListToGameEventHandler(); + /*! Brings the tab of the given game to front if it is open in this session. */ + bool switchToGameTab(int gameId); TabGame(TabSupervisor *_tabSupervisor, GameReplay *replay); ~TabGame() override; void retranslateUi() override; @@ -210,6 +216,19 @@ public: return game; } + [[nodiscard]] QStackedWidget *getMainWidget() const + { + return mainWidget; + } + [[nodiscard]] QVBoxLayout *getDeckViewContainerLayout() const + { + return deckViewContainerLayout; + } + [[nodiscard]] QWidget *getDeckViewContainerWidget() const + { + return deckViewContainerWidget; + } + public slots: void viewCardInfo(const CardRef &cardRef = {}) const; void resetForRewind(); diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp new file mode 100644 index 000000000..708f19fb6 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp @@ -0,0 +1,206 @@ +#include "tournament_tab_game_extension.h" + +#include "../../../game/game_event_handler.h" +#include "../../../game/player/player_logic.h" +#include "../../widgets/dialogs/dlg_tournament_settings.h" +#include "../../widgets/draft/tournament_widget.h" +#include "tab_game.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include + +TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObject(parent), tabGame(parent) +{ + tournamentOverviewWidget = new QWidget(parent); + auto *overviewLayout = new QVBoxLayout(tournamentOverviewWidget); + + auto *headerLayout = new QHBoxLayout; + backToGameButton = new QPushButton(tournamentOverviewWidget); + connect(backToGameButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showDeckViewPage); + headerLayout->addWidget(backToGameButton); + headerLayout->addStretch(); + overviewLayout->addLayout(headerLayout); + + tournamentWidget = new TournamentWidget(tournamentOverviewWidget); + tournamentWidget->setLocalPlayerId(parent->getGame()->getPlayerManager()->getLocalPlayerId()); + overviewLayout->addWidget(tournamentWidget); + + parent->getMainWidget()->addWidget(tournamentOverviewWidget); + + deckViewStatusLabel = new QLabel(parent->getDeckViewContainerWidget()); + deckViewStatusLabel->setStyleSheet("color: palette(placeholderText);"); + deckViewStatusLabel->setVisible(false); + + standingsButton = new QPushButton(parent->getDeckViewContainerWidget()); + connect(standingsButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showOverviewPage); + standingsButton->setVisible(false); + + connectSignals(); +} + +bool TournamentTabGameExtension::isLocalPlayerHost() const +{ + return tabGame->getGame()->getPlayerManager()->getLocalPlayerId() == + tabGame->getGame()->getGameState()->getHostId(); +} + +void TournamentTabGameExtension::connectSignals() +{ + auto *handler = tabGame->getGame()->getGameEventHandler(); + connect(handler, &GameEventHandler::tournamentStateChanged, this, + &TournamentTabGameExtension::onTournamentStateChanged); + connect(tournamentWidget, &TournamentWidget::openMatchGameRequested, this, + &TournamentTabGameExtension::openMatchGame); +} + +void TournamentTabGameExtension::initializeTournamentMode() +{ + if (!tabGame) { + return; + } + + auto *deckLayout = tabGame->getDeckViewContainerLayout(); + int index = 0; + deckLayout->insertWidget(index++, deckViewStatusLabel); + if (isLocalPlayerHost()) { + settingsButton = new QPushButton(tabGame->getDeckViewContainerWidget()); + connect(settingsButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showTournamentSettingsDialog); + deckLayout->insertWidget(index++, settingsButton); + } + deckLayout->insertWidget(index++, standingsButton); + deckLayout->insertSpacing(index, 4); +} + +void TournamentTabGameExtension::retranslateUi() +{ + backToGameButton->setText(tr("Back to game view")); + standingsButton->setText(tr("Tournament standings")); + if (settingsButton) { + settingsButton->setText(tr("Tournament Settings")); + } + tournamentWidget->retranslateUi(); +} + +void TournamentTabGameExtension::updateDeckViewStrip(const Event_TournamentState &state) +{ + int submitted = 0; + for (int i = 0; i < state.players_size(); ++i) { + if (state.players(i).deck_submitted()) { + ++submitted; + } + } + + QString phaseText; + switch (state.phase()) { + case Event_TournamentState::PHASE_DECK_BUILDING: + phaseText = tr("Deck building"); + break; + case Event_TournamentState::PHASE_PLAYING: + phaseText = tr("Round in progress"); + break; + case Event_TournamentState::PHASE_FINISHED: + phaseText = tr("Finished"); + break; + default: + phaseText = tr("Unknown phase"); + break; + } + + // The submission count only matters while players are still picking decks. + QString text = + state.phase() == Event_TournamentState::PHASE_DECK_BUILDING + ? tr("Tournament: %1 - %2/%3 decks submitted").arg(phaseText).arg(submitted).arg(state.players_size()) + : tr("Tournament: %1").arg(phaseText); + deckViewStatusLabel->setText(text); + deckViewStatusLabel->setVisible(true); +} + +void TournamentTabGameExtension::updateNavigationButtons(const Event_TournamentState &state) +{ + bool showStandings = + state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED; + standingsButton->setVisible(showStandings); + if (settingsButton) { + settingsButton->setVisible(state.phase() == Event_TournamentState::PHASE_DECK_BUILDING); + } +} + +void TournamentTabGameExtension::onTournamentStateChanged(const Event_TournamentState &state) +{ + if (!tabGame) { + return; + } + + updateDeckViewStrip(state); + updateNavigationButtons(state); + tournamentWidget->updateTournamentState(state); + + // Switch to the standings page only when the phase itself changes, so score + // updates never yank the user away while they are looking at their deck. + // Instant switches are the app-wide baseline today; motion and sound cues + // for this transition are deferred to the Game dressing phase. + bool overviewPhase = + state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED; + if (overviewPhase && (!hasLastKnownPhase || state.phase() != lastKnownPhase)) { + tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget); + } + lastKnownPhase = state.phase(); + hasLastKnownPhase = true; +} + +void TournamentTabGameExtension::showOverviewPage() +{ + if (tabGame) { + tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget); + } +} + +void TournamentTabGameExtension::showDeckViewPage() +{ + if (tabGame) { + tabGame->getMainWidget()->setCurrentWidget(tabGame->getDeckViewContainerWidget()); + } +} + +void TournamentTabGameExtension::showTournamentSettingsDialog() +{ + DlgTournamentSettings dlg(tabGame); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + DlgTournamentSettingsResult result = dlg.getResult(); + + PlayerLogic *localPlayer = tabGame->getGame()->getPlayerManager()->getActiveLocalPlayer(-1); + if (!localPlayer) { + TabSupervisor::actShowPopup(tr("You are not an active player in this game.")); + return; + } + + Command_TournamentSettingsSelect cmd; + cmd.mutable_settings()->set_games_per_match(result.gamesPerMatch); + tabGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, localPlayer->getPlayerInfo()->getId()); +} + +void TournamentTabGameExtension::openMatchGame(int gameId) +{ + if (!tabGame || gameId <= 0) { + return; + } + + // Players are auto-joined into their match game by the server, so the tab + // usually already exists and switching is enough. Otherwise (e.g. after + // leaving the match) fall back to joining as spectator. + if (tabGame->switchToGameTab(gameId)) { + return; + } + + int roomId = tabGame->getGame()->getGameMetaInfo()->proto().room_id(); + tabGame->getTabSupervisor()->joinGameFromUserCard(gameId, roomId, true); +} diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h new file mode 100644 index 000000000..dec5038af --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h @@ -0,0 +1,57 @@ +#ifndef TOURNAMENT_TAB_GAME_EXTENSION_H +#define TOURNAMENT_TAB_GAME_EXTENSION_H + +#include +#include +#include + +class QLabel; +class QPushButton; +class QWidget; +class TournamentWidget; +class TabGame; + +/** + * @class TournamentTabGameExtension + * @ingroup GameViews + * @brief Attaches tournament behavior to a TabGame hosting a tournament hub game. + * + * Owns the tournament overview page inside the tab's stacked main widget, + * mirrors tournament state onto the always-visible deck-view status strip, + * and routes user intents (open match game, change settings, switch pages) + * between the two views. + */ +class TournamentTabGameExtension : public QObject +{ + Q_OBJECT +public: + explicit TournamentTabGameExtension(TabGame *parent); + + void initializeTournamentMode(); + void retranslateUi(); + +private slots: + void onTournamentStateChanged(const Event_TournamentState &state); + void showTournamentSettingsDialog(); + void showOverviewPage(); + void showDeckViewPage(); + void openMatchGame(int gameId); + +private: + void connectSignals(); + [[nodiscard]] bool isLocalPlayerHost() const; + void updateDeckViewStrip(const Event_TournamentState &state); + void updateNavigationButtons(const Event_TournamentState &state); + + QPointer tabGame; + TournamentWidget *tournamentWidget = nullptr; + QWidget *tournamentOverviewWidget = nullptr; + QPushButton *backToGameButton = nullptr; + QPushButton *standingsButton = nullptr; + QPushButton *settingsButton = nullptr; + QLabel *deckViewStatusLabel = nullptr; + Event_TournamentState::TournamentPhase lastKnownPhase = Event_TournamentState::PHASE_DECK_BUILDING; + bool hasLastKnownPhase = false; +}; + +#endif // TOURNAMENT_TAB_GAME_EXTENSION_H From a700f8d00dbf72be2bc1e7157c986aac78982655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 11:35:23 +0200 Subject: [PATCH 11/14] [Client] Address tournament UI review feedback - Drop the in-game tournament-settings button: Command_TournamentSettingsSelect has no server handler (RespContextError), the host-ness decision was evaluated once at tab construction, and the button never received a label on first show. The dialog can be re-added together with a working Server_Player override. - Pass PlayerManager::isSpectator() to TournamentWidget instead of relying on localPlayerId == -1, which never holds for spectators. - Report games per match as the total series length (Best of %1) to match the DlgTournamentSettings hint and the server's use of the value; the previous gamesPerMatch * 2 - 1 mislabeled Bo3 as Best of 5. - Block signals around read-only tournamentCheckBox setChecked so the disabled settings button is not re-enabled, and seed Command_CreateGame through mutable_tournament_settings() (field 15 became the settings message). - return after closing a sub-game that routed to its parent tab. --- .../widgets/dialogs/dlg_create_game.cpp | 8 +++- .../widgets/draft/tournament_widget.cpp | 13 ++++-- .../widgets/draft/tournament_widget.h | 2 + .../src/interface/widgets/tabs/tab_game.cpp | 9 ++-- .../tabs/tournament_tab_game_extension.cpp | 41 +------------------ .../tabs/tournament_tab_game_extension.h | 3 -- 6 files changed, 21 insertions(+), 55 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 4b2b4b6c2..936c12104 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -245,7 +246,10 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); - tournamentCheckBox->setChecked(gameInfo.is_tournament()); + { + const QSignalBlocker blocker(tournamentCheckBox); + tournamentCheckBox->setChecked(gameInfo.is_tournament()); + } QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -317,7 +321,7 @@ void DlgCreateGame::actOK() cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); cmd.set_is_tournament(tournamentCheckBox->isChecked()); if (tournamentCheckBox->isChecked()) { - cmd.set_games_per_match(tournamentSettings.gamesPerMatch); + cmd.mutable_tournament_settings()->set_games_per_match(tournamentSettings.gamesPerMatch); } auto _gameTypes = QString(); diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.cpp b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp index 542341bb2..e7aa356b4 100644 --- a/cockatrice/src/interface/widgets/draft/tournament_widget.cpp +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp @@ -75,6 +75,11 @@ void TournamentWidget::setLocalPlayerId(int playerId) localPlayerId = playerId; } +void TournamentWidget::setIsSpectator(bool spectator) +{ + isSpectator = spectator; +} + QString TournamentWidget::getPlayerName(const Event_TournamentState &state, int playerId) const { for (int i = 0; i < state.players_size(); ++i) { @@ -93,15 +98,15 @@ void TournamentWidget::updateTournamentState(const Event_TournamentState &state) QString statusText; switch (state.phase()) { case Event_TournamentState::PHASE_DECK_BUILDING: - statusText = gamesPerMatch > 1 ? tr("Tournament - Deck building (Best of %1)").arg(gamesPerMatch * 2 - 1) + statusText = gamesPerMatch > 1 ? tr("Tournament - Deck building (Best of %1)").arg(gamesPerMatch) : tr("Tournament - Deck building"); break; case Event_TournamentState::PHASE_PLAYING: - statusText = gamesPerMatch > 1 ? tr("Tournament - Playing (Best of %1)").arg(gamesPerMatch * 2 - 1) + statusText = gamesPerMatch > 1 ? tr("Tournament - Playing (Best of %1)").arg(gamesPerMatch) : tr("Tournament - Playing"); break; case Event_TournamentState::PHASE_FINISHED: - statusText = gamesPerMatch > 1 ? tr("Tournament - Finished (Best of %1)").arg(gamesPerMatch * 2 - 1) + statusText = gamesPerMatch > 1 ? tr("Tournament - Finished (Best of %1)").arg(gamesPerMatch) : tr("Tournament - Finished"); break; default: @@ -134,7 +139,7 @@ void TournamentWidget::updateOpenMatchButton() openMatchButton->setEnabled(true); openMatchButton->setText(tr("Open match game")); openMatchButton->setToolTip(tr("Switch to your current match game")); - } else if (localPlayerId == -1 && hasAnyLivePairing) { + } else if (isSpectator && hasAnyLivePairing) { // Spectators have no pairing of their own but may watch any running match. openMatchButton->setEnabled(true); openMatchButton->setText(tr("Spectate live match")); diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.h b/cockatrice/src/interface/widgets/draft/tournament_widget.h index 6bb656d6c..4346c79df 100644 --- a/cockatrice/src/interface/widgets/draft/tournament_widget.h +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.h @@ -27,6 +27,7 @@ public: void updateTournamentState(const Event_TournamentState &state); void setLocalPlayerId(int playerId); + void setIsSpectator(bool spectator); void retranslateUi(); signals: @@ -47,6 +48,7 @@ private: QPushButton *openMatchButton; int localPlayerId = -1; + bool isSpectator = false; int currentGameId = -1; bool hasOwnLivePairing = false; bool hasAnyLivePairing = false; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index e35b41480..106b8d6e9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -989,12 +989,9 @@ bool TabGame::switchToGameTab(int gameId) void TabGame::closeGame() { int parentId = game->getGameMetaInfo()->parentGameId(); - if (parentId >= 0) { - if (switchToGameTab(parentId)) { - close(); - } - // If the parent tab is gone, fall through to the normal leave-game - // flow instead of stranding the user on a dead sub-game tab. + if (parentId >= 0 && switchToGameTab(parentId)) { + close(); + return; } gameMenu->clear(); diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp index 708f19fb6..ceadd6dad 100644 --- a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp @@ -1,8 +1,6 @@ #include "tournament_tab_game_extension.h" #include "../../../game/game_event_handler.h" -#include "../../../game/player/player_logic.h" -#include "../../widgets/dialogs/dlg_tournament_settings.h" #include "../../widgets/draft/tournament_widget.h" #include "tab_game.h" #include "tab_supervisor.h" @@ -12,7 +10,6 @@ #include #include #include -#include #include TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObject(parent), tabGame(parent) @@ -29,6 +26,7 @@ TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObjec tournamentWidget = new TournamentWidget(tournamentOverviewWidget); tournamentWidget->setLocalPlayerId(parent->getGame()->getPlayerManager()->getLocalPlayerId()); + tournamentWidget->setIsSpectator(parent->getGame()->getPlayerManager()->isSpectator()); overviewLayout->addWidget(tournamentWidget); parent->getMainWidget()->addWidget(tournamentOverviewWidget); @@ -44,12 +42,6 @@ TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObjec connectSignals(); } -bool TournamentTabGameExtension::isLocalPlayerHost() const -{ - return tabGame->getGame()->getPlayerManager()->getLocalPlayerId() == - tabGame->getGame()->getGameState()->getHostId(); -} - void TournamentTabGameExtension::connectSignals() { auto *handler = tabGame->getGame()->getGameEventHandler(); @@ -68,11 +60,6 @@ void TournamentTabGameExtension::initializeTournamentMode() auto *deckLayout = tabGame->getDeckViewContainerLayout(); int index = 0; deckLayout->insertWidget(index++, deckViewStatusLabel); - if (isLocalPlayerHost()) { - settingsButton = new QPushButton(tabGame->getDeckViewContainerWidget()); - connect(settingsButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showTournamentSettingsDialog); - deckLayout->insertWidget(index++, settingsButton); - } deckLayout->insertWidget(index++, standingsButton); deckLayout->insertSpacing(index, 4); } @@ -81,9 +68,6 @@ void TournamentTabGameExtension::retranslateUi() { backToGameButton->setText(tr("Back to game view")); standingsButton->setText(tr("Tournament standings")); - if (settingsButton) { - settingsButton->setText(tr("Tournament Settings")); - } tournamentWidget->retranslateUi(); } @@ -126,9 +110,6 @@ void TournamentTabGameExtension::updateNavigationButtons(const Event_TournamentS bool showStandings = state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED; standingsButton->setVisible(showStandings); - if (settingsButton) { - settingsButton->setVisible(state.phase() == Event_TournamentState::PHASE_DECK_BUILDING); - } } void TournamentTabGameExtension::onTournamentStateChanged(const Event_TournamentState &state) @@ -168,26 +149,6 @@ void TournamentTabGameExtension::showDeckViewPage() } } -void TournamentTabGameExtension::showTournamentSettingsDialog() -{ - DlgTournamentSettings dlg(tabGame); - if (dlg.exec() != QDialog::Accepted) { - return; - } - - DlgTournamentSettingsResult result = dlg.getResult(); - - PlayerLogic *localPlayer = tabGame->getGame()->getPlayerManager()->getActiveLocalPlayer(-1); - if (!localPlayer) { - TabSupervisor::actShowPopup(tr("You are not an active player in this game.")); - return; - } - - Command_TournamentSettingsSelect cmd; - cmd.mutable_settings()->set_games_per_match(result.gamesPerMatch); - tabGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, localPlayer->getPlayerInfo()->getId()); -} - void TournamentTabGameExtension::openMatchGame(int gameId) { if (!tabGame || gameId <= 0) { diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h index dec5038af..0300194c3 100644 --- a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.h @@ -32,14 +32,12 @@ public: private slots: void onTournamentStateChanged(const Event_TournamentState &state); - void showTournamentSettingsDialog(); void showOverviewPage(); void showDeckViewPage(); void openMatchGame(int gameId); private: void connectSignals(); - [[nodiscard]] bool isLocalPlayerHost() const; void updateDeckViewStrip(const Event_TournamentState &state); void updateNavigationButtons(const Event_TournamentState &state); @@ -48,7 +46,6 @@ private: QWidget *tournamentOverviewWidget = nullptr; QPushButton *backToGameButton = nullptr; QPushButton *standingsButton = nullptr; - QPushButton *settingsButton = nullptr; QLabel *deckViewStatusLabel = nullptr; Event_TournamentState::TournamentPhase lastKnownPhase = Event_TournamentState::PHASE_DECK_BUILDING; bool hasLastKnownPhase = false; From c433b165e57b88c8ab49acb9eac1141685a7c08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 14:59:04 +0200 Subject: [PATCH 12/14] [Tournament] Fold tournament settings hint into the spinbox tooltip The standalone hint label sat in the form layout's field column with an empty label cell, which misaligned it against the row above. Per review, drop the label and carry its text in the spinbox tooltip instead. --- .../interface/widgets/dialogs/dlg_tournament_settings.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp index 49dbf065a..cb69e042d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tournament_settings.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include @@ -15,13 +14,9 @@ DlgTournamentSettings::DlgTournamentSettings(QWidget *parent) : QDialog(parent) gamesPerMatchSpin = new QSpinBox(this); gamesPerMatchSpin->setRange(1, 5); gamesPerMatchSpin->setValue(1); - gamesPerMatchSpin->setToolTip(tr("Number of games per match (e.g., 3 for Best of 3)")); + gamesPerMatchSpin->setToolTip(tr("Number of games per match (e.g., 3 for Best of 3, 5 for Best of 5)")); mainLayout->addRow(tr("Games per match:"), gamesPerMatchSpin); - QLabel *hintLabel = new QLabel(tr("Set to 3 for Best of 3, 5 for Best of 5, etc."), this); - hintLabel->setStyleSheet("color: palette(placeholderText);"); - mainLayout->addRow(QString(), hintLabel); - buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTournamentSettings::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); From cf76d42791897c0423ee96709ae80fe63f0ac399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 17:32:03 +0200 Subject: [PATCH 13/14] [Client] Skip decided pairings when resolving live tournament game --- cockatrice/src/interface/widgets/draft/tournament_widget.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/draft/tournament_widget.cpp b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp index e7aa356b4..8693b3da7 100644 --- a/cockatrice/src/interface/widgets/draft/tournament_widget.cpp +++ b/cockatrice/src/interface/widgets/draft/tournament_widget.cpp @@ -185,7 +185,7 @@ void TournamentWidget::rebuildTables(const Event_TournamentState &state) QString statusStr; if (pairing.player2_id() == -1) { statusStr = tr("BYE"); - } else if (pairing.winner_id() != -1) { + } else if (pairing.has_winner_id() || pairing.is_draw()) { statusStr = tr("Finished"); } else { statusStr = tr("vs"); @@ -211,7 +211,7 @@ void TournamentWidget::resolveCurrentGameId(const Event_TournamentState &state) for (int i = 0; i < state.pairings_size(); ++i) { const auto &pairing = state.pairings(i); - if (pairing.game_id() == -1) { + if (pairing.game_id() == -1 || pairing.has_winner_id() || pairing.is_draw()) { continue; } if (!hasOwnLivePairing && localPlayerId != -1 && From c1bce86888bcac11da2026b8729e597469d7d55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 3 Nov 2026 00:32:44 +0100 Subject: [PATCH 14/14] [Client] Fix tournament UI includes after the game logic move The game logic now lives in libcockatrice_game, so the tab's relative includes into cockatrice/src/game no longer resolve. Include the library headers instead and pull in the complete types the tab dereferences. --- .../interface/widgets/tabs/tournament_tab_game_extension.cpp | 5 ++++- vcpkg | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp index ceadd6dad..03b46286d 100644 --- a/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp +++ b/cockatrice/src/interface/widgets/tabs/tournament_tab_game_extension.cpp @@ -1,6 +1,5 @@ #include "tournament_tab_game_extension.h" -#include "../../../game/game_event_handler.h" #include "../../widgets/draft/tournament_widget.h" #include "tab_game.h" #include "tab_supervisor.h" @@ -10,6 +9,10 @@ #include #include #include +#include +#include +#include +#include #include TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObject(parent), tabGame(parent) diff --git a/vcpkg b/vcpkg index 9eec002df..9e593bb18 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 +Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d