diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index e11a962d1..bd9ec0c0c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -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 diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index a6313e312..ea0b36ea5 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -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); 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 131ff1077..600f93dff 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -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 #include #include #include +#include #include #include #include @@ -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); +} 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 1ed4fe4ca..e6dc34697 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -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 #include #include #include +#include #include #include #include #include +#include #include #include 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 replayList; GameReplay *currentReplay; + bool isTournament; + TournamentSettings tournamentSettings; + Server_Tournament *tournament; + QPointer tournamentParentGame; + int tournamentMatchPlayer1Id; + int tournamentMatchPlayer2Id; + bool disconnectRemovesPlayer; + QScopedPointer deckValidationStrategy; QScopedPointer 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 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 diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp new file mode 100644 index 000000000..65529072f --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -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 +#include +#include +#include +#include + +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 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 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 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> 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(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(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> 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(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)); + } 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); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h new file mode 100644 index 000000000..438490e84 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -0,0 +1,116 @@ +#ifndef SERVER_TOURNAMENT_H +#define SERVER_TOURNAMENT_H + +#include +#include +#include +#include +#include +#include +#include + +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 matchGameIds; + }; + +private: + 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; + 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 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 new file mode 100644 index 000000000..73035dd94 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp @@ -0,0 +1,33 @@ +#include "server_tournament_lifecycle_strategy.h" + +#include "server_abstract_player.h" +#include "server_game.h" + +#include + +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; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h new file mode 100644 index 000000000..c6dbede1b --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h @@ -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 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 new file mode 100644 index 000000000..7ca8aacc9 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp @@ -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 +#include + +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; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h new file mode 100644 index 000000000..7bc3bafb8 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h @@ -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 diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 8422d703d..a06271850 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -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(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(qBound(1, gamesPerMatch, MAX_GAMES_PER_MATCH))); + } game->addPlayer(this, rc, asSpectator, asJudge, false); room->addGame(game);