From 3c03d740f37ee9b259a7a6f0aa34bd27829c7b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 24 Aug 2026 08:43:46 +0200 Subject: [PATCH 1/9] [Protocol] Add tournament state event and tournament game commands Adds the protocol layer for the tournament game mode: - Event_TournamentState (2027) carrying phase, round counters, player standings, pairings, and settings (games_per_match) - Command_ReportMatchResult / Command_AdvanceTournament / Command_TournamentSettingsSelect game command extensions (1037-1039) - is_tournament flag on Command_CreateGame and ServerInfo_Game; is_tournament and parent_game_id on Event_GameStateChanged Took 13 minutes --- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 + .../protocol/pb/command_tournament.proto | 26 +++++++++++ .../pb/event_game_state_changed.proto | 6 +++ .../protocol/pb/event_tournament_state.proto | 43 +++++++++++++++++++ .../protocol/pb/game_commands.proto | 9 ++++ .../protocol/pb/game_event.proto | 1 + .../protocol/pb/room_commands.proto | 6 +++ .../protocol/pb/serverinfo_game.proto | 3 ++ 8 files changed, 96 insertions(+) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 3a193ae3c..6708b06cb 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -60,6 +60,7 @@ set(PROTO_FILES command_set_sideboard_lock.proto command_set_sideboard_plan.proto command_shuffle.proto + command_tournament.proto command_undo_draw.proto commands.proto context_concede.proto @@ -118,6 +119,7 @@ set(PROTO_FILES event_set_card_counter.proto event_set_counter.proto event_shuffle.proto + event_tournament_state.proto event_user_joined.proto event_user_left.proto event_user_message.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto new file mode 100644 index 000000000..ee958f587 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto @@ -0,0 +1,26 @@ +syntax = "proto2"; +import "game_commands.proto"; +import "event_tournament_state.proto"; + +message Command_ReportMatchResult { + extend GameCommand { + optional Command_ReportMatchResult ext = 1037; + } + + optional sint32 game_id = 1 [default = -1]; + optional sint32 winner_id = 2 [default = -1]; +} + +message Command_AdvanceTournament { + extend GameCommand { + optional Command_AdvanceTournament ext = 1038; + } +} + +message Command_TournamentSettingsSelect { + extend GameCommand { + optional Command_TournamentSettingsSelect ext = 1039; + } + + optional TournamentSettings settings = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto index 5c8aee3fe..c085e6a59 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto @@ -24,4 +24,10 @@ message Event_GameStateChanged { // the amount of seconds since the game started optional uint32 seconds_elapsed = 5; + + // whether this game is a tournament game + optional bool is_tournament = 9; + + // for tournament sub-games: the ID of the parent tournament game (-1 if not a sub-game) + optional sint32 parent_game_id = 10 [default = -1]; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto new file mode 100644 index 000000000..5fb003f50 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto @@ -0,0 +1,43 @@ +syntax = "proto2"; +import "game_event.proto"; + +message TournamentPlayer { + optional sint32 player_id = 1; + optional string player_name = 2; + optional uint32 wins = 3; + optional uint32 losses = 4; + optional uint32 draws = 5; + optional bool deck_submitted = 6; +} + +message TournamentPairing { + optional sint32 player1_id = 1; + optional sint32 player2_id = 2 [default = -1]; + optional sint32 game_id = 3; + optional sint32 winner_id = 4 [default = -1]; + optional sint32 player1_match_wins = 5 [default = 0]; + optional sint32 player2_match_wins = 6 [default = 0]; +} + +message TournamentSettings { + optional uint32 games_per_match = 1 [default = 1]; +} + +message Event_TournamentState { + extend GameEvent { + optional Event_TournamentState ext = 2027; + } + + enum TournamentPhase { + PHASE_DECK_BUILDING = 0; + PHASE_PLAYING = 1; + PHASE_FINISHED = 2; + } + + optional TournamentPhase phase = 1; + optional uint32 current_round = 2; + optional uint32 total_rounds = 3; + repeated TournamentPlayer players = 4; + repeated TournamentPairing pairings = 5; + optional TournamentSettings settings = 6; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 2e5b88978..68a79eb6b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -180,6 +180,15 @@ message GameCommand { /// Server: Server_Player::cmdSetPlaymat /// Client: reflected via player properties changed event SET_PLAYMAT = 1035; + + /// Report the result of a tournament match sub-game. + REPORT_MATCH_RESULT = 1037; + + /// Advance the tournament to the next round. + ADVANCE_TOURNAMENT = 1038; + + /// Select tournament settings. + TOURNAMENT_SETTINGS_SELECT = 1039; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto index 7d3147701..b952bfdef 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto @@ -34,6 +34,7 @@ message GameEvent { CHANGE_ZONE_PROPERTIES = 2020; REVERSE_TURN = 2021; GAME_LOG_NOTICE = 2022; + TOURNAMENT_STATE = 2027; } optional sint32 player_id = 1 [default = -1]; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index a8c90ec6c..bf4d5310f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -69,6 +69,12 @@ message Command_CreateGame { // share decklists with all players when selected optional bool share_decklists_on_load = 14; + + // number of games per match in tournament mode (e.g. 3 for best of 3) + optional uint32 games_per_match = 15 [default = 1]; + + // whether this is a tournament game + optional bool is_tournament = 16; } message Command_JoinGame { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9989ae18a..4ac004cf4 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -65,4 +65,7 @@ message ServerInfo_Game { // the current host of the game, which may differ from the creator after a host transfer optional ServerInfo_User host_info = 53; + + // whether this game is a tournament game + optional bool is_tournament = 54; } From b98a267285679bb79b57753c798592b96d9bac40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 08:43:25 +0200 Subject: [PATCH 2/9] [Protocol] Share tournament messages and report draws explicitly - TournamentPlayer/TournamentPairing/TournamentSettings move into serverinfo_tournament.proto, imported by both the tournament event and the tournament game commands, so Command_CreateGame reaches them without crossing the command/event boundary - Command_CreateGame carries the full TournamentSettings instead of a duplicate games_per_match field, keeping game creation and mid-game changes on one wire representation - draw results are explicit: Command_ReportMatchResult and TournamentPairing gain is_draw, so a report stays distinguishable from an unset -1 winner - pairing match-wins are uint32 like the other game counts --- .../libcockatrice/protocol/pb/CMakeLists.txt | 1 + .../protocol/pb/command_tournament.proto | 9 ++++-- .../protocol/pb/event_tournament_state.proto | 25 ++------------- .../protocol/pb/room_commands.proto | 5 +-- .../protocol/pb/serverinfo_tournament.proto | 32 +++++++++++++++++++ 5 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 6708b06cb..5dcf4b23f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -186,6 +186,7 @@ set(PROTO_FILES serverinfo_replay_match.proto serverinfo_report.proto serverinfo_room.proto + serverinfo_tournament.proto serverinfo_user.proto serverinfo_user_alt.proto serverinfo_user_session.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto index ee958f587..f465ed6f7 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto @@ -1,6 +1,6 @@ syntax = "proto2"; import "game_commands.proto"; -import "event_tournament_state.proto"; +import "serverinfo_tournament.proto"; message Command_ReportMatchResult { extend GameCommand { @@ -8,7 +8,12 @@ message Command_ReportMatchResult { } optional sint32 game_id = 1 [default = -1]; + // The winning player's ID. A drawn match is reported with is_draw set and + // winner_id left unset so a missing report stays distinguishable from a + // reported draw via has_winner_id(). optional sint32 winner_id = 2 [default = -1]; + // Whether the match ended in a draw; winner_id is meaningless then. + optional bool is_draw = 3; } message Command_AdvanceTournament { @@ -23,4 +28,4 @@ message Command_TournamentSettingsSelect { } optional TournamentSettings settings = 1; -} +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto index 5fb003f50..cfaaa6f01 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto @@ -1,27 +1,6 @@ syntax = "proto2"; import "game_event.proto"; - -message TournamentPlayer { - optional sint32 player_id = 1; - optional string player_name = 2; - optional uint32 wins = 3; - optional uint32 losses = 4; - optional uint32 draws = 5; - optional bool deck_submitted = 6; -} - -message TournamentPairing { - optional sint32 player1_id = 1; - optional sint32 player2_id = 2 [default = -1]; - optional sint32 game_id = 3; - optional sint32 winner_id = 4 [default = -1]; - optional sint32 player1_match_wins = 5 [default = 0]; - optional sint32 player2_match_wins = 6 [default = 0]; -} - -message TournamentSettings { - optional uint32 games_per_match = 1 [default = 1]; -} +import "serverinfo_tournament.proto"; message Event_TournamentState { extend GameEvent { @@ -40,4 +19,4 @@ message Event_TournamentState { repeated TournamentPlayer players = 4; repeated TournamentPairing pairings = 5; optional TournamentSettings settings = 6; -} +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index bf4d5310f..e2f46e338 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -1,4 +1,5 @@ syntax = "proto2"; +import "serverinfo_tournament.proto"; message RoomCommand { enum RoomCommandType { LEAVE_ROOM = 1000; @@ -70,8 +71,8 @@ message Command_CreateGame { // share decklists with all players when selected optional bool share_decklists_on_load = 14; - // number of games per match in tournament mode (e.g. 3 for best of 3) - optional uint32 games_per_match = 15 [default = 1]; + // tournament settings shared by game creation and mid-game changes + optional TournamentSettings tournament_settings = 15; // whether this is a tournament game optional bool is_tournament = 16; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto new file mode 100644 index 000000000..217967ff8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto @@ -0,0 +1,32 @@ +syntax = "proto2"; + +// Player-entered tournament metadata as shown in the standings and bracket views. +message TournamentPlayer { + optional sint32 player_id = 1; + optional string player_name = 2; + optional uint32 wins = 3; + optional uint32 losses = 4; + optional uint32 draws = 5; + optional bool deck_submitted = 6; +} + +// A single match pairing within a tournament round. +message TournamentPairing { + optional sint32 player1_id = 1; + // -1 when the pairing is a bye. + optional sint32 player2_id = 2 [default = -1]; + optional sint32 game_id = 3; + // The winning player's ID, or -1 when the match ended in a draw (is_draw). + // An unset field reads back as -1 too; has_winner_id() distinguishes those. + optional sint32 winner_id = 4 [default = -1]; + optional uint32 player1_match_wins = 5; + optional uint32 player2_match_wins = 6; + // Whether the match ended in a draw; winner_id is meaningless then. + optional bool is_draw = 7; +} + +// Settings governing all matches of a tournament. +message TournamentSettings { + // Number of games per match (e.g. 3 for best of 3). + optional uint32 games_per_match = 1 [default = 1]; +} \ No newline at end of file From b8083b6c93affc38a212a6760f1b802eb7b0e6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 10:27:51 +0200 Subject: [PATCH 3/9] [Protocol] Drop redundant is_tournament from the game-state event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client already learns a game is a tournament from Event_GameJoined's ServerInfo_Game.is_tournament, so mirroring it on every Event_GameStateChanged is dead wire data — nothing on the client reads it. Removed. --- .../libcockatrice/protocol/pb/event_game_state_changed.proto | 3 --- 1 file changed, 3 deletions(-) diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto index c085e6a59..ae14cb44b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto @@ -25,9 +25,6 @@ message Event_GameStateChanged { // the amount of seconds since the game started optional uint32 seconds_elapsed = 5; - // whether this game is a tournament game - optional bool is_tournament = 9; - // for tournament sub-games: the ID of the parent tournament game (-1 if not a sub-game) optional sint32 parent_game_id = 10 [default = -1]; } From 9dd16ec0fcf734a297e444b5d19bf9ff6483888e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 24 Aug 2026 08:54:54 +0200 Subject: [PATCH 4/9] [Server] Add extension points for tournament game commands Adds the server-side seams the tournament engine will plug into: - Dispatch ReportMatchResult / AdvanceTournament / TournamentSettingsSelect in processGameCommand; base implementations return RespContextError so regular games reject them safely - Server_AbstractPlayer::setDeck lets a backend install a deck into a player slot directly - Server_Room::getUserInterfaceByName resolves a room user by name for automated game setup Took 24 minutes --- .../game/server_abstract_participant.cpp | 32 +++++++++++++++++++ .../remote/game/server_abstract_participant.h | 10 ++++++ .../remote/game/server_abstract_player.cpp | 6 ++++ .../remote/game/server_abstract_player.h | 1 + .../network/server/remote/server_room.cpp | 12 +++++++ .../network/server/remote/server_room.h | 2 ++ 6 files changed, 63 insertions(+) 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 a6280ffa1..a6313e312 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 @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -536,6 +537,15 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::SET_PLAYMAT: return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); break; + case GameCommand::REPORT_MATCH_RESULT: + return cmdReportMatchResult(command.GetExtension(Command_ReportMatchResult::ext), rc, ges); + break; + case GameCommand::ADVANCE_TOURNAMENT: + return cmdAdvanceTournament(command.GetExtension(Command_AdvanceTournament::ext), rc, ges); + break; + case GameCommand::TOURNAMENT_SETTINGS_SELECT: + return cmdTournamentSettingsSelect(command.GetExtension(Command_TournamentSettingsSelect::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } @@ -584,3 +594,25 @@ void Server_AbstractParticipant::getInfo(ServerInfo_Player *info, { getProperties(*info->mutable_properties(), withUserInfo); } + +Response::ResponseCode Server_AbstractParticipant::cmdReportMatchResult(const Command_ReportMatchResult & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespContextError; +} + +Response::ResponseCode Server_AbstractParticipant::cmdAdvanceTournament(const Command_AdvanceTournament & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespContextError; +} + +Response::ResponseCode +Server_AbstractParticipant::cmdTournamentSettingsSelect(const Command_TournamentSettingsSelect & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespContextError; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index c78ae78c1..b41ecbb20 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -53,6 +53,9 @@ class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; class Command_SetPlaymat; +class Command_ReportMatchResult; +class Command_AdvanceTournament; +class Command_TournamentSettingsSelect; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -175,6 +178,13 @@ public: cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges); virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode + cmdReportMatchResult(const Command_ReportMatchResult &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode + cmdAdvanceTournament(const Command_AdvanceTournament &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdTournamentSettingsSelect(const Command_TournamentSettingsSelect &cmd, + ResponseContainer &rc, + GameEventStorage &ges); Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges); void sendGameEvent(const GameEventContainer &event); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index 957a89792..a9d486a4d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -1663,3 +1663,9 @@ void Server_AbstractPlayer::getPlayerProperties(ServerInfo_PlayerProperties &res playmatParams->set_zoom(playmat.params.zoom); } } + +void Server_AbstractPlayer::setDeck(DeckList *_deck) +{ + delete deck; + deck = _deck; +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index 85fbc0557..312f62d0e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -47,6 +47,7 @@ public: { return deck; } + void setDeck(DeckList *_deck); bool getReadyStart() const { return readyStart; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 1f29e62fb..f6880e9ff 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -363,6 +363,18 @@ void Server_Room::broadcastGameListUpdate(const ServerInfo_Game &gameInfo, bool sendRoomEvent(prepareRoomEvent(event), sendToIsl); } +Server_AbstractUserInterface *Server_Room::getUserInterfaceByName(const QString &name) const +{ + usersLock.lockForRead(); + auto it = users.constFind(name); + Server_AbstractUserInterface *result = nullptr; + if (it != users.constEnd()) { + result = it.value(); + } + usersLock.unlock(); + return result; +} + void Server_Room::addGame(Server_Game *game) { ServerInfo_Room roomInfo; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index 3d9988f20..02b865ebc 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -135,6 +135,8 @@ public: void addGame(Server_Game *game); void removeGame(Server_Game *game); + Server_AbstractUserInterface *getUserInterfaceByName(const QString &name) const; + void sendRoomEvent(RoomEvent *event, bool sendToIsl = true); RoomEvent *prepareRoomEvent(const ::google::protobuf::Message &roomEvent); }; From 38b1ac44f92964dc060dfb7c04035c8cbd08daed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 24 Aug 2026 10:09:37 +0200 Subject: [PATCH 5/9] [Server] Implement Swiss tournament engine for lobby games Adds the server-side tournament mode driven through the strategy and factory interfaces from #7132: - Server_Tournament runs greedy Swiss pairing with rematch avoidance, byes, ceil(log2(n)) rounds, and best-of-N series via gamesPerMatch; match sub-games are spawned through the parent game's Server_MatchGameFactory implementation with players auto-joined and their submitted decks installed - Tournament lifecycle strategy gates the hub game start until all decks are submitted; match result strategy reports sub-game outcomes back to the parent tournament - Server_Game gains tournament state (isTournament flag, settings, parent link), protocol fields in getInfo/game state events, a full-lobby bypass for tournament hubs, and disconnectRemovesPlayer plumbing for upcoming draft modes - Match-game creation is deferred to the owning thread's event loop so room registration never nests lock orders; shared state is guarded by the tournament mutex --- .../network/server/remote/CMakeLists.txt | 8 +- .../game/server_abstract_participant.cpp | 2 +- .../server/remote/game/server_game.cpp | 101 +++- .../network/server/remote/game/server_game.h | 57 +- .../server/remote/game/server_tournament.cpp | 556 ++++++++++++++++++ .../server/remote/game/server_tournament.h | 106 ++++ .../server_tournament_lifecycle_strategy.cpp | 33 ++ .../server_tournament_lifecycle_strategy.h | 12 + ...erver_tournament_match_result_strategy.cpp | 34 ++ .../server_tournament_match_result_strategy.h | 12 + .../server/remote/server_protocolhandler.cpp | 7 + 11 files changed, 922 insertions(+), 6 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_lifecycle_strategy.h create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.h 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 43209e994..d5d552b31 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -30,10 +30,14 @@ #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 @@ -62,8 +66,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() { @@ -265,6 +270,12 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } + event->set_is_tournament(isTournament); + + if (tournamentParentGame) { + event->set_parent_game_id(tournamentParentGame->getGameId()); + } + for (Server_AbstractParticipant *participant : participants.values()) { participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo); } @@ -313,7 +324,7 @@ 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; } @@ -861,6 +872,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); } } @@ -911,3 +923,86 @@ 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())); + } + + 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 1b9f651bd..3e655839d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -24,6 +24,7 @@ #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 @@ -34,21 +35,26 @@ #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 +89,14 @@ private: QList replayList; GameReplay *currentReplay; + bool isTournament; + TournamentSettings tournamentSettings; + Server_Tournament *tournament; + Server_Game *tournamentParentGame; + int tournamentMatchPlayer1Id; + int tournamentMatchPlayer2Id; + bool disconnectRemovesPlayer; + QScopedPointer deckValidationStrategy; QScopedPointer lifecycleStrategy; @@ -220,6 +234,45 @@ 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); + Server_Game *getTournamentParentGame() const + { + return tournamentParentGame; + } + bool getDisconnectRemovesPlayer() const + { + return 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(); @@ -232,6 +285,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..1d13f31f7 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -0,0 +1,556 @@ +#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) +{ + TournamentPlayerData data; + data.playerId = playerId; + data.playerName = playerName; + 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); +} + +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) { + 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; + }); + + // Simple greedy Swiss pairing + QSet paired; + 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; + } + if (!havePlayed(available[i], available[j])) { + TournamentPairingData pairing; + pairing.player1Id = available[i]; + pairing.player2Id = available[j]; + currentPairings.append(pairing); + paired.insert(available[i]); + paired.insert(available[j]); + break; + } + } + } + + // Bye for unpaired player if odd count + for (int i = 0; i < available.size(); ++i) { + if (!paired.contains(available[i])) { + // Player gets a bye (auto-win) + TournamentPairingData bye; + bye.player1Id = available[i]; + bye.player2Id = -1; + bye.winnerId = available[i]; + bye.player1MatchWins = gamesPerMatch; // Match immediately decided + currentPairings.append(bye); + players[available[i]].wins += 1; + allPreviousPairings.append(qMakePair(available[i], -1)); + break; + } + } +} + +int Server_Tournament::calculateTotalRounds() const +{ + int n = players.size(); + 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 && + pairing.matchGameIds.size() < static_cast(gamesPerMatch)) { + 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; + } + } + } + + // Create a sub-game for this match via the factory + ServerInfo_User creatorInfo; + creatorInfo.set_name(player1Name.toStdString()); + creatorInfo.set_user_level(ServerInfo_User::IsAdmin | ServerInfo_User::IsRegistered); + + 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); + 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. + 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 + 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); + } + } + + // Start the match game + matchGame->startGameIfReady(true); +} + +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): no match wins incremented + + // Check if match is decided + const int gamesNeeded = static_cast(gamesPerMatch); + matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded); + + if (matchDecided) { + // Determine match winner + int matchWinnerId; + if (pairingPtr->player1MatchWins >= gamesNeeded) { + matchWinnerId = pairingPtr->player1Id; + } else { + matchWinnerId = pairingPtr->player2Id; + } + + // 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 { + players[pairingPtr->player2Id].wins += 1; + players[pairingPtr->player1Id].losses += 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); + } +} + +void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) +{ + 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); + // Map internal sentinel: -2 (undecided) -> -1 (no winner yet in proto) + p->set_winner_id(pairing.winnerId == -2 ? -1 : pairing.winnerId); + p->set_player1_match_wins(pairing.player1MatchWins); + p->set_player2_match_wins(pairing.player2MatchWins); + } + + ges.enqueueGameEvent(state, -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..b59edda4f --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -0,0 +1,106 @@ +#ifndef SERVER_TOURNAMENT_H +#define SERVER_TOURNAMENT_H + +#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); + 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); + + 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; + }; + + 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: + Server_Game *parentGame; + Server_MatchGameFactory *matchGameFactory; + mutable QRecursiveMutex tournamentMutex; + QMap players; + QMap submittedDecks; + QList currentPairings; + 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..5149b9a26 --- /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() != 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..5611224e5 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament_match_result_strategy.cpp @@ -0,0 +1,34 @@ +#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) +{ + auto *parentGame = game->getTournamentParentGame(); + 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 899df6529..a3802441f 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" @@ -916,6 +917,8 @@ 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_games_per_match() ? static_cast(cmd.games_per_match()) : 1; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { @@ -940,6 +943,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); From fd25d102bfd81a60f011fefcc008acd986e4f884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 08:57:26 +0200 Subject: [PATCH 6/9] [Server] Harden Swiss tournament engine against drops and stalls - link match sub-games to the hub through QPointer so a finished match can never dereference a torn-down parent game - a player who leaves the hub is dropped: no longer paired, current undecided match awarded to the opponent, absent from the bracket - refuse to spawn a match game when either participant is disconnected, and set disconnectRemovesPlayer on match games so a mid-match disconnect ends it instead of leaving a half-present participant - match winner is a strict majority (gamesPerMatch/2+1); an exhausted series with no majority is recorded as a draw so the round always advances - match games are started without force-start: a missing deck no longer kicks the player; the game stays open for deck selection - buyes are handed to every leftover player, worst-ranked first, at most one per player over the tournament - tournament hubs cannot start on mere 'everyone ready': host force-start is required and fewer than two players never starts - sub-game creator copies the real player user info instead of fabricating IsAdmin --- .../server/remote/game/server_game.cpp | 24 ++ .../network/server/remote/game/server_game.h | 9 +- .../server/remote/game/server_tournament.cpp | 212 ++++++++++++++---- .../server/remote/game/server_tournament.h | 10 +- .../server_tournament_lifecycle_strategy.cpp | 2 +- ...erver_tournament_match_result_strategy.cpp | 5 +- 6 files changed, 216 insertions(+), 46 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index d5d552b31..f0b154fc1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -328,6 +328,13 @@ void Server_Game::doStartGameIfReady(bool 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; + } + auto players = getPlayers(); for (auto *player : players.values()) { if (!player->getReadyStart()) { @@ -578,6 +585,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()) { @@ -957,6 +975,12 @@ void Server_Game::startTournament() 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) { + qCWarning() << "Cannot start tournament with fewer than 2 players"; + return; + } + tournament->startTournament(); } 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 3e655839d..2eeebd11b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -92,7 +93,7 @@ private: bool isTournament; TournamentSettings tournamentSettings; Server_Tournament *tournament; - Server_Game *tournamentParentGame; + QPointer tournamentParentGame; int tournamentMatchPlayer1Id; int tournamentMatchPlayer2Id; bool disconnectRemovesPlayer; @@ -250,7 +251,7 @@ public: void startTournament(); void setPlayerTournamentDeck(int playerId, DeckList *deck); void setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id); - Server_Game *getTournamentParentGame() const + QPointer getTournamentParentGame() const { return tournamentParentGame; } @@ -258,6 +259,10 @@ public: { return disconnectRemovesPlayer; } + void setDisconnectRemovesPlayer(bool _disconnectRemovesPlayer) + { + disconnectRemovesPlayer = _disconnectRemovesPlayer; + } // Server_MatchGameFactory implementation Server_Game *createMatchGame(const GameConfig &config, int &outGameId) override; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp index 1d13f31f7..527f2e728 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -31,9 +31,11 @@ Server_Tournament::~Server_Tournament() 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; } @@ -51,6 +53,41 @@ 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() @@ -97,7 +134,9 @@ void Server_Tournament::generateSwissPairings() currentPairings.clear(); QList available; for (auto it = players.constBegin(); it != players.constEnd(); ++it) { - available.append(it->playerId); + if (!it->dropped) { + available.append(it->playerId); + } } // Sort by wins descending (and by record for tie-breaking) @@ -113,8 +152,10 @@ void Server_Tournament::generateSwissPairings() return a < b; }); - // Simple greedy Swiss pairing 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; @@ -123,38 +164,74 @@ void Server_Tournament::generateSwissPairings() if (paired.contains(available[j])) { continue; } - if (!havePlayed(available[i], available[j])) { - TournamentPairingData pairing; - pairing.player1Id = available[i]; - pairing.player2Id = available[j]; - currentPairings.append(pairing); - paired.insert(available[i]); - paired.insert(available[j]); - break; + 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; } } - // Bye for unpaired player if odd count - for (int i = 0; i < available.size(); ++i) { - if (!paired.contains(available[i])) { - // Player gets a bye (auto-win) - TournamentPairingData bye; - bye.player1Id = available[i]; - bye.player2Id = -1; - bye.winnerId = available[i]; - bye.player1MatchWins = gamesPerMatch; // Match immediately decided - currentPairings.append(bye); - players[available[i]].wins += 1; - allPreviousPairings.append(qMakePair(available[i], -1)); - 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 = players.size(); + int n = 0; + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (!it->dropped) { + ++n; + } + } if (n <= 1) { return 0; } @@ -208,10 +285,16 @@ void Server_Tournament::enqueueMatchGameCreation() { QMutexLocker locker(&tournamentMutex); for (const auto &pairing : currentPairings) { - if (pairing.player2Id != -1 && pairing.winnerId == -2 && - pairing.matchGameIds.size() < static_cast(gamesPerMatch)) { - planned.append(qMakePair(pairing.player1Id, pairing.player2Id)); + 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()) { @@ -277,12 +360,25 @@ void Server_Tournament::createMatchGame(int player1Id, int 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 + // 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; - creatorInfo.set_name(player1Name.toStdString()); - creatorInfo.set_user_level(ServerInfo_User::IsAdmin | ServerInfo_User::IsRegistered); + 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) @@ -302,6 +398,9 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) 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 @@ -317,6 +416,9 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) } // 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) { @@ -338,7 +440,8 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) } joiners.clear(); - // Set decks and mark players as ready in the match game + // 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()); @@ -352,11 +455,21 @@ void Server_Tournament::createMatchGame(int player1Id, int player2Id) if (!deckNative.isEmpty()) { matchPlayer->setDeck(new DeckList(deckNative)); matchPlayer->setReadyStart(true); + } else { + anyDeckMissing = true; } } - // Start the match game - matchGame->startGameIfReady(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) @@ -440,20 +553,29 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game } else if (winnerId == pairingPtr->player2Id) { pairingPtr->player2MatchWins += 1; } - // Draw (winnerId == -1): no match wins incremented + // 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. - // Check if match is decided - const int gamesNeeded = static_cast(gamesPerMatch); + // 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; + int matchWinnerId = -1; if (pairingPtr->player1MatchWins >= gamesNeeded) { matchWinnerId = pairingPtr->player1Id; - } else { + } 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; @@ -462,9 +584,12 @@ bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, Game if (matchWinnerId == pairingPtr->player1Id) { players[pairingPtr->player1Id].wins += 1; players[pairingPtr->player2Id].losses += 1; - } else { + } 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 @@ -546,8 +671,13 @@ void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) p->set_player1_id(pairing.player1Id); p->set_player2_id(pairing.player2Id); p->set_game_id(pairing.gameId); - // Map internal sentinel: -2 (undecided) -> -1 (no winner yet in proto) - p->set_winner_id(pairing.winnerId == -2 ? -1 : pairing.winnerId); + // -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); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h index b59edda4f..aeb3ea732 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,9 @@ public: 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); @@ -68,6 +72,7 @@ public: int losses = 0; int draws = 0; bool deckSubmitted = false; + bool dropped = false; }; struct TournamentPairingData @@ -82,12 +87,15 @@ public: }; private: - Server_Game *parentGame; + 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; 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 index 5149b9a26..73035dd94 100644 --- 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 @@ -11,7 +11,7 @@ Server_GameLifecycleStrategy::StartAction Server_TournamentLifecycleStrategy::on { // Match sub-games start through the normal flow; only the tournament hub game is // managed by this lifecycle. - if (game->getTournamentParentGame() != nullptr) { + if (game->getTournamentParentGame().data() != nullptr) { return StartAction::ProceedNormal; } 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 index 5611224e5..7ca8aacc9 100644 --- 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 @@ -12,7 +12,10 @@ bool Server_TournamentMatchResultStrategy::onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) { - auto *parentGame = game->getTournamentParentGame(); + // 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; } From 4580ab5a3b234d04a498130b04f686658ec899a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 09:47:27 +0200 Subject: [PATCH 7/9] [Server] Adapt tournament engine to the shared settings message - read games per match from Command_CreateGame.tournament_settings after the proto rework replaced the standalone games_per_match field - use qWarning to match the surrounding file --- .../libcockatrice/network/server/remote/game/server_game.cpp | 2 +- .../network/server/remote/server_protocolhandler.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index f0b154fc1..0098be19d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -977,7 +977,7 @@ void Server_Game::startTournament() // A tournament with fewer than two players can't produce a valid bracket. if (tournament->getPlayerCount() < 2) { - qCWarning() << "Cannot start tournament with fewer than 2 players"; + qWarning() << "Cannot start tournament with fewer than 2 players"; return; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index a3802441f..cdde723c6 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -918,7 +918,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room 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_games_per_match() ? static_cast(cmd.games_per_match()) : 1; + int gamesPerMatch = + cmd.has_tournament_settings() ? static_cast(cmd.tournament_settings().games_per_match()) : 1; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { From 9fad30bd4b2561b2f51720e149718155969fcd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 09:49:53 +0200 Subject: [PATCH 8/9] [Server] Replay tournament state to late joiners The bracket, phase and standings live in Event_TournamentState, which only flows on mutation. Without a copy a player or spectator joining after round one would sit on an empty bracket until the next advance, so the current state is now enqueued as part of the join snapshot. Extracts Event_TournamentState building into buildStateEvent() so both the broadcast path and the join path share one source of truth. --- .../network/server/remote/game/server_game.cpp | 9 +++++++++ .../network/server/remote/game/server_tournament.cpp | 9 +++++++-- .../network/server/remote/game/server_tournament.h | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 0098be19d..16c012806 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -812,6 +812,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, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp index 527f2e728..65529072f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.cpp @@ -635,7 +635,7 @@ void Server_Tournament::checkAndAdvanceRound(GameEventStorage &ges) } } -void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) +Event_TournamentState Server_Tournament::buildStateEvent() const { QMutexLocker locker(&tournamentMutex); @@ -682,5 +682,10 @@ void Server_Tournament::broadcastTournamentState(GameEventStorage &ges) p->set_player2_match_wins(pairing.player2MatchWins); } - ges.enqueueGameEvent(state, -1); + 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 index aeb3ea732..438490e84 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_tournament.h @@ -36,6 +36,8 @@ public: 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 { From 4584891cf13063648f98cda4660657a493bcbeaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 10:28:12 +0200 Subject: [PATCH 9/9] [Server] Stop mirroring is_tournament on the game-state event The client learns tournament status from Event_GameJoined's ServerInfo_Game; mirroring it on every Event_GameStateChanged was dead wire data. The field was removed from the proto in [Protocol]; drop the now-invalidated setter. --- .../libcockatrice/network/server/remote/game/server_game.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 16c012806..ff0ce02e4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -270,8 +270,6 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } - event->set_is_tournament(isTournament); - if (tournamentParentGame) { event->set_parent_game_id(tournamentParentGame->getGameId()); }