[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.
This commit is contained in:
Lukas Brübach 2026-10-11 17:37:30 +02:00
parent 84a8b751d3
commit d2c93aee7b
3 changed files with 138 additions and 88 deletions

View file

@ -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);
}

View file

@ -57,7 +57,8 @@ 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)) {
@ -89,6 +90,9 @@ void Server_Tournament::dropPlayer(int playerId)
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,11 +368,42 @@ 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
@ -543,10 +579,14 @@ 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;
}
broadcastTournamentState(ges);
// Fall through to checkAndAdvanceRound below.
matchDecided = true;
seriesContinues = false;
} else {
// Increment per-match wins
if (winnerId == pairingPtr->player1Id) {
pairingPtr->player1MatchWins += 1;
@ -560,11 +600,13 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game
const int gamesPlayed = pairingPtr->matchGameIds.size();
const int gamesNeeded = static_cast<int>(gamesPerMatch / 2 + 1);
const int gamesRemaining = static_cast<int>(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);
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) {
@ -574,6 +616,12 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game
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).
@ -602,6 +650,7 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game
}
broadcastTournamentState(ges);
} // else (undecided pairing)
}
if (seriesContinues) {

View file

@ -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);