From 18fac5c5b9022d96d38b968d49559c4fea2173da 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] [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 d5d552b31..f0b154fc1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -328,6 +328,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()) { @@ -578,6 +585,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()) { @@ -957,6 +975,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 3e655839d..2eeebd11b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -92,7 +93,7 @@ private: bool isTournament; TournamentSettings tournamentSettings; Server_Tournament *tournament; - Server_Game *tournamentParentGame; + QPointer tournamentParentGame; int tournamentMatchPlayer1Id; int tournamentMatchPlayer2Id; bool disconnectRemovesPlayer; @@ -250,7 +251,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; } @@ -258,6 +259,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; }