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] [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);