This commit is contained in:
BruebachL 2026-09-20 19:36:33 +02:00 committed by GitHub
commit a4802c1ccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1107 additions and 6 deletions

View file

@ -14,9 +14,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

View file

@ -580,7 +580,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);

View file

@ -30,11 +30,15 @@
#include "server_cardzone.h"
#include "server_player.h"
#include "server_spectator.h"
#include "server_tournament.h"
#include "server_tournament_lifecycle_strategy.h"
#include "server_tournament_match_result_strategy.h"
#include <QDebug>
#include <QElapsedTimer>
#include <QRegularExpression>
#include <QTimer>
#include <algorithm>
#include <google/protobuf/descriptor.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/context_connection_state_changed.pb.h>
@ -63,8 +67,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()
{
@ -277,6 +282,10 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event,
event->set_game_started(false);
}
if (tournamentParentGame) {
event->set_parent_game_id(tournamentParentGame->getGameId());
}
for (Server_AbstractParticipant *participant : participants.values()) {
participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo);
}
@ -325,7 +334,14 @@ 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;
}
// 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;
}
@ -583,6 +599,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()) {
@ -799,6 +826,15 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
}
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1));
// 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,
@ -877,6 +913,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);
}
}
@ -927,3 +964,92 @@ 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()));
}
// A tournament with fewer than two players can't produce a valid bracket.
if (tournament->getPlayerCount() < 2) {
qWarning() << "Cannot start tournament with fewer than 2 players";
return;
}
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);
}

View file

@ -24,31 +24,38 @@
#include "game_config.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"
#include <QDateTime>
#include <QMap>
#include <QMutex>
#include <QObject>
#include <QPointer>
#include <QScopedPointer>
#include <QSet>
#include <QStringList>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
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;
class Event_GameStateChanged;
class Server_Game : public QObject
class Server_Game : public QObject, public Server_MatchGameFactory
{
Q_OBJECT
private:
@ -83,6 +90,14 @@ private:
QList<GameReplay *> replayList;
GameReplay *currentReplay;
bool isTournament;
TournamentSettings tournamentSettings;
Server_Tournament *tournament;
QPointer<Server_Game> tournamentParentGame;
int tournamentMatchPlayer1Id;
int tournamentMatchPlayer2Id;
bool disconnectRemovesPlayer;
QScopedPointer<Server_DeckValidationStrategy> deckValidationStrategy;
QScopedPointer<Server_GameLifecycleStrategy> lifecycleStrategy;
@ -222,6 +237,49 @@ 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);
QPointer<Server_Game> getTournamentParentGame() const
{
return tournamentParentGame;
}
bool getDisconnectRemovesPlayer() const
{
return disconnectRemovesPlayer;
}
void setDisconnectRemovesPlayer(bool _disconnectRemovesPlayer)
{
disconnectRemovesPlayer = _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();
@ -234,6 +292,8 @@ public:
{
return lifecycleStrategy.data();
}
/** @brief Replace the match result strategy; takes ownership of @p strategy. */
void setMatchResultStrategy(Server_MatchResultStrategy *strategy);
};
#endif

View file

@ -0,0 +1,691 @@
#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 <QLoggingCategory>
#include <algorithm>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
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)
{
QMutexLocker locker(&tournamentMutex);
TournamentPlayerData data;
data.playerId = playerId;
data.playerName = playerName;
data.dropped = false;
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);
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()
{
{
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<int> available;
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (!it->dropped) {
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;
});
QSet<int> 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;
}
for (int j = i + 1; j < available.size(); ++j) {
if (paired.contains(available[j])) {
continue;
}
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;
}
}
// Give a bye to every remaining unpaired eligible player, worst-ranked first.
// A player receives at most one bye over the whole tournament.
QList<int> 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 = 0;
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (!it->dropped) {
++n;
}
}
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<QPair<int, int>> planned;
{
QMutexLocker locker(&tournamentMutex);
for (const auto &pairing : currentPairings) {
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<int>(gamesPerMatch)) {
continue;
}
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<int>(gamesPerMatch)) {
qCWarning(TournamentLog) << "Refusing to exceed series length for pairing" << player1Id << 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, 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;
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)
: 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);
// 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
{
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.
// 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<int, QPair<Server_AbstractUserInterface *, ResponseContainer *>> 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.
bool anyDeckMissing = false;
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);
} else {
anyDeckMissing = 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)
{
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): 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<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);
}
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));
} 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);
}
}
Event_TournamentState Server_Tournament::buildStateEvent() const
{
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);
// -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);
}
return state;
}
void Server_Tournament::broadcastTournamentState(GameEventStorage &ges)
{
ges.enqueueGameEvent(buildStateEvent(), -1);
}

View file

@ -0,0 +1,116 @@
#ifndef SERVER_TOURNAMENT_H
#define SERVER_TOURNAMENT_H
#include <QList>
#include <QMap>
#include <QObject>
#include <QPointer>
#include <QRecursiveMutex>
#include <QSet>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
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);
// 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);
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
{
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;
bool dropped = 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<int> matchGameIds;
};
private:
QPointer<Server_Game> parentGame;
Server_MatchGameFactory *matchGameFactory;
mutable QRecursiveMutex tournamentMutex;
QMap<int, TournamentPlayerData> players;
QMap<int, DeckList *> submittedDecks;
QList<TournamentPairingData> 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<int> byeGivenPlayers;
QList<QPair<int, int>> 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

View file

@ -0,0 +1,33 @@
#include "server_tournament_lifecycle_strategy.h"
#include "server_abstract_player.h"
#include "server_game.h"
#include <QLoggingCategory>
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().data() != 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;
}

View file

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

View file

@ -0,0 +1,37 @@
#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 <libcockatrice/protocol/pb/event_game_closed.pb.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
bool Server_TournamentMatchResultStrategy::onGameFinished(Server_Game *game,
int playing,
Server_AbstractPlayer *lastPlayer)
{
// 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;
}
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;
}

View file

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

View file

@ -3,6 +3,7 @@
#include "game/game_config.h"
#include "game/server_game.h"
#include "game/server_player.h"
#include "game/server_tournament.h"
#include "server_database_interface.h"
#include "server_room.h"
@ -946,6 +947,9 @@ 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_tournament_settings() ? static_cast<int>(cmd.tournament_settings().games_per_match()) : 1;
const int gameId = databaseInterface->getNextGameId();
if (gameId == -1) {
@ -970,6 +974,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<uint32_t>(qBound(1, gamesPerMatch, MAX_GAMES_PER_MATCH)));
}
game->addPlayer(this, rc, asSpectator, asJudge, false);
room->addGame(game);