[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 // 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 // bracket: a mere "everyone current is ready" must not start a 1-player or
// undersized tournament. startTournament() additionally enforces 2+ players. // 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; return;
} }
@ -633,7 +634,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve
if (tournament && !tournamentParentGame && !spectator) { if (tournament && !tournamentParentGame && !spectator) {
const int leavingPlayerId = participant->getPlayerId(); const int leavingPlayerId = participant->getPlayerId();
GameEventStorage tournGes; GameEventStorage tournGes;
tournament->dropPlayer(leavingPlayerId); tournament->dropPlayer(leavingPlayerId, tournGes);
tournament->broadcastTournamentState(tournGes); tournament->broadcastTournamentState(tournGes);
tournGes.sendToGame(this); tournGes.sendToGame(this);
} }

View file

@ -57,7 +57,8 @@ void Server_Tournament::removePlayer(int playerId)
byeGivenPlayers.remove(playerId); byeGivenPlayers.remove(playerId);
} }
void Server_Tournament::dropPlayer(int playerId) void Server_Tournament::dropPlayer(int playerId, GameEventStorage &ges)
{
{ {
QMutexLocker locker(&tournamentMutex); QMutexLocker locker(&tournamentMutex);
if (!players.contains(playerId)) { if (!players.contains(playerId)) {
@ -89,6 +90,9 @@ void Server_Tournament::dropPlayer(int playerId)
allPreviousPairings.append(qMakePair(pairing.player1Id, pairing.player2Id)); 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() void Server_Tournament::startTournament()
{ {
@ -332,6 +336,7 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id)
QString deck2Native; QString deck2Native;
int round = 0; int round = 0;
int gameNumber = 1; int gameNumber = 1;
bool settledWithoutGame = false;
{ {
QMutexLocker locker(&tournamentMutex); QMutexLocker locker(&tournamentMutex);
player1Name = players.value(player1Id).playerName; 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 // 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. // 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 qCWarning(TournamentLog) << "Skipping match creation: a player in pairing" << player1Id << player2Id
<< "is no longer connected"; << "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 // 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 // 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) { if (pairingPtr->winnerId != -2) {
return true; broadcastTournamentState(ges);
} // Fall through to checkAndAdvanceRound below.
matchDecided = true;
seriesContinues = false;
} else {
// Increment per-match wins // Increment per-match wins
if (winnerId == pairingPtr->player1Id) { if (winnerId == pairingPtr->player1Id) {
pairingPtr->player1MatchWins += 1; pairingPtr->player1MatchWins += 1;
@ -560,11 +600,13 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game
const int gamesPlayed = pairingPtr->matchGameIds.size(); const int gamesPlayed = pairingPtr->matchGameIds.size();
const int gamesNeeded = static_cast<int>(gamesPerMatch / 2 + 1); const int gamesNeeded = static_cast<int>(gamesPerMatch / 2 + 1);
const int gamesRemaining = static_cast<int>(gamesPerMatch) - gamesPlayed; const int gamesRemaining = static_cast<int>(gamesPerMatch) - gamesPlayed;
matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded); matchDecided =
if (!matchDecided) { (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded);
// Series exhausted without a strict-majority winner (e.g. a drawn Bo3 if (!matchDecided && gamesRemaining <= 0) {
// leaves it 1-1): record the match as a draw so the round always advances. // Series exhausted: draws can leave the wins unequal without either
matchDecided = (gamesRemaining <= 0) && (pairingPtr->player1MatchWins == pairingPtr->player2MatchWins); // 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) { if (matchDecided) {
@ -574,6 +616,12 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game
matchWinnerId = pairingPtr->player1Id; matchWinnerId = pairingPtr->player1Id;
} else if (pairingPtr->player2MatchWins >= gamesNeeded) { } else if (pairingPtr->player2MatchWins >= gamesNeeded) {
matchWinnerId = pairingPtr->player2Id; 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). // 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); broadcastTournamentState(ges);
} // else (undecided pairing)
} }
if (seriesContinues) { if (seriesContinues) {

View file

@ -30,7 +30,7 @@ public:
void removePlayer(int playerId); void removePlayer(int playerId);
// Marks an already-starting/started tournament player as dropped: they stop // Marks an already-starting/started tournament player as dropped: they stop
// being paired and their outstanding unstarted match is awarded as a loss. // 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 startTournament();
void advanceRound(GameEventStorage &ges); void advanceRound(GameEventStorage &ges);
void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges); void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges);