Compare commits

...

13 commits

Author SHA1 Message Date
Lukas Brübach
6c9c144318 [Client] Address tournament UI review feedback
- Drop the in-game tournament-settings button: Command_TournamentSettingsSelect
  has no server handler (RespContextError), the host-ness decision was evaluated
  once at tab construction, and the button never received a label on first show.
  The dialog can be re-added together with a working Server_Player override.
- Pass PlayerManager::isSpectator() to TournamentWidget instead of relying on
  localPlayerId == -1, which never holds for spectators.
- Report games per match as the total series length (Best of %1) to match the
  DlgTournamentSettings hint and the server's use of the value; the previous
  gamesPerMatch * 2 - 1 mislabeled Bo3 as Best of 5.
- Block signals around read-only tournamentCheckBox setChecked so the disabled
  settings button is not re-enabled, and seed Command_CreateGame through
  mutable_tournament_settings() (field 15 became the settings message).
- return after closing a sub-game that routed to its parent tab.
2026-09-02 11:35:23 +02:00
Lukas Brübach
39aba980f8 [Client] Add tournament UI for creating and following lobby tournaments
Wires the tournament protocol into the client:
- DlgCreateGame gains a tournament checkbox with a games-per-match
  settings sub-dialog; both values are sent with Command_CreateGame and
  mirrored in the join-info variant of the dialog
- TournamentWidget renders live standings (with deck-submission state)
  and pairings for the local player's perspective, and asks to open the
  current match game via openMatchGameRequested()
- TournamentTabGameExtension attaches an overview page to TabGame's
  stacked views, mirrors phase and submission progress onto a deck-view
  status strip, and provides round-trip navigation between game view and
  standings; hosts get an in-game settings dialog during deck building
- Open-match falls back to joining as spectator through TabSupervisor;
  sub-games now return to their parent tab on close, falling back to
  the normal leave-game flow when the parent is gone
2026-09-02 10:50:29 +02:00
Lukas Brübach
e275b4ed96 [Client] Drop dead player event handler extension points
The second PlayerLogic constructor taking a custom PlayerEventHandler was UB
(converting this to PlayerLogic* in the base initializer, and connecting a
freshly-built handler against a not-yet-constructed object), and its abstract
extension surface — virtual initializeZones, virtual getPlayerEventHandler,
virtual processGameEvent, protected player member — has no subclass consumer in
the stack. Revert to the single primary constructor.

Also:
- Drop the unused GameMetaInfo::setIsTournament setter. Tournament status is
  authoritative from Event_GameJoined's ServerInfo_Game; the redundant
  is_tournament field on Event_GameStateChanged is removed in [Protocol].
- Gate the parent-game routing on has_parent_game_id() instead of comparing the
  proto default, so a future drop of the default keeps working.
2026-09-02 10:29:01 +02:00
Lukas Brübach
43b6bcc1da [Client] Add tournament event plumbing and player extension points
Wires the client game layer for tournament state without touching
existing behavior:
- GameEventHandler dispatches Event_TournamentState (2027) to a new
  tournamentStateChanged signal
- GameMetaInfo exposes isTournament and parentGameId over the new
  ServerInfo_Game field
- PlayerEventHandler::processGameEvent is virtual and player is
  protected; PlayerLogic gains a protected constructor accepting a
  custom handler, so mode-specific subclasses can intercept events
2026-09-02 10:29:01 +02:00
Lukas Brübach
4584891cf1 [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.
2026-09-02 10:28:49 +02:00
Lukas Brübach
9fad30bd4b [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.
2026-09-02 10:28:49 +02:00
Lukas Brübach
4580ab5a3b [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
2026-09-02 10:28:49 +02:00
Lukas Brübach
fd25d102bf [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
2026-09-02 10:28:49 +02:00
Lukas Brübach
38b1ac44f9 [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
2026-09-02 10:28:49 +02:00
Lukas Brübach
9dd16ec0fc [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
2026-09-02 10:28:43 +02:00
Lukas Brübach
b8083b6c93 [Protocol] Drop redundant is_tournament from the game-state event
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.
2026-09-02 10:27:51 +02:00
Lukas Brübach
b98a267285 [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
2026-09-02 08:43:25 +02:00
Lukas Brübach
3c03d740f3
[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
2026-08-31 13:49:15 +02:00
41 changed files with 1995 additions and 9 deletions

View file

@ -53,6 +53,7 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
src/interface/widgets/dialogs/dlg_tournament_settings.cpp
src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp
@ -215,6 +216,7 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
src/interface/widgets/deck_editor/deck_state_manager.cpp
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/draft/tournament_widget.cpp
src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp
src/interface/widgets/general/display/banner_widget.cpp
@ -391,6 +393,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_server.cpp
src/interface/widgets/tabs/tab_supervisor.cpp
src/interface/widgets/tabs/tab_visual_database_display.cpp
src/interface/widgets/tabs/tournament_tab_game_extension.cpp
src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp
src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp
src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp

View file

@ -26,6 +26,7 @@
#include <libcockatrice/protocol/pb/event_reverse_turn.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_player.pb.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pending_command.h>
@ -158,6 +159,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont,
case GameEvent::REVERSE_TURN:
eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context);
break;
case GameEvent::TOURNAMENT_STATE:
emit tournamentStateChanged(event.GetExtension(Event_TournamentState::ext));
break;
default: {
PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0);
@ -263,6 +267,13 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
int /*eventPlayerId*/,
const GameEventContext & /*context*/)
{
// Sub-games of a tournament report their parent hub game so the client can
// route "close game" back to the parent tab. Use has_parent_game_id rather
// than comparing the default so a future drop of the proto default still works.
if (event.has_parent_game_id()) {
game->getGameMetaInfo()->setParentGameId(event.parent_game_id());
}
const int playerListSize = event.player_list_size();
QVector<QPair<int, QPair<QString, QString>>> opponentDecksToDisplay;

View file

@ -43,6 +43,7 @@ class Event_SetActivePhase;
class Event_GameSay;
class Event_Kicked;
class Event_ReverseTurn;
class Event_TournamentState;
class Event_Ping;
inline Q_LOGGING_CATEGORY(GameEventHandlerLog, "game_event_handler");
@ -329,6 +330,7 @@ signals:
void gameStopped();
void gameClosed();
void tournamentStateChanged(const Event_TournamentState &state);
void playerPropertiesChanged(const ServerInfo_PlayerProperties &prop, int playerId);
void playerJoined(const ServerInfo_PlayerProperties &playerInfo);
void playerLeft(int leavingPlayerId);

View file

@ -84,6 +84,21 @@ public:
return roomGameTypes.find(gameInfo_.game_types(index)).value();
}
bool isTournament() const
{
return gameInfo_.is_tournament();
}
int parentGameId() const
{
return parentGameId_;
}
void setParentGameId(int id)
{
parentGameId_ = id;
}
public slots:
void setStarted(bool s)
{
@ -108,6 +123,7 @@ signals:
private:
ServerInfo_Game gameInfo_;
int parentGameId_ = -1;
};
#endif // GAME_META_INFO_H

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../interface/widgets/tabs/tab_room.h"
#include "dlg_tournament_settings.h"
#include <QApplication>
#include <QCheckBox>
@ -14,6 +15,7 @@
#include <QPushButton>
#include <QRadioButton>
#include <QSet>
#include <QSignalBlocker>
#include <QSpinBox>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
#include <libcockatrice/protocol/pending_command.h>
@ -104,14 +106,29 @@ void DlgCreateGame::sharedCtor()
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
tournamentCheckBox = new QCheckBox(tr("Tournament mode"));
tournamentCheckBox->setToolTip(tr("All players submit a deck up front and rounds are paired automatically"));
tournamentSettingsButton = new QPushButton(tr("Settings..."));
tournamentSettingsButton->setEnabled(false);
connect(tournamentCheckBox, &QCheckBox::toggled, tournamentSettingsButton, &QPushButton::setEnabled);
connect(tournamentSettingsButton, &QPushButton::clicked, this, [this] {
DlgTournamentSettings dlg(this);
dlg.setCurrentGamesPerMatch(tournamentSettings.gamesPerMatch);
if (dlg.exec() == QDialog::Accepted) {
tournamentSettings = dlg.getResult();
}
});
createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge"));
auto *gameSetupOptionsLayout = new QGridLayout;
gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0);
gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1);
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0);
gameSetupOptionsLayout->addWidget(tournamentCheckBox, 2, 0);
gameSetupOptionsLayout->addWidget(tournamentSettingsButton, 2, 1);
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0);
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0);
} else {
createGameAsJudgeCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setHidden(true);
@ -188,7 +205,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameType
}
DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QString> &_gameTypes, QWidget *parent)
: QDialog(parent), room(0), gameTypes(_gameTypes)
: QDialog(parent), room(nullptr), gameTypes(_gameTypes)
{
sharedCtor();
@ -205,6 +222,8 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
createGameAsSpectatorCheckBox->setEnabled(false);
startingLifeTotalEdit->setEnabled(false);
shareDecklistsOnLoadCheckBox->setEnabled(false);
tournamentCheckBox->setEnabled(false);
tournamentSettingsButton->setEnabled(false);
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
maxPlayersEdit->setValue(gameInfo.max_players());
@ -215,6 +234,10 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
{
const QSignalBlocker blocker(tournamentCheckBox);
tournamentCheckBox->setChecked(gameInfo.is_tournament());
}
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
@ -252,6 +275,8 @@ void DlgCreateGame::actReset()
startingLifeTotalEdit->setValue(20);
shareDecklistsOnLoadCheckBox->setChecked(false);
tournamentCheckBox->setChecked(false);
tournamentSettings = DlgTournamentSettingsResult{};
createGameAsJudgeCheckBox->setChecked(false);
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -282,6 +307,10 @@ void DlgCreateGame::actOK()
cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked());
cmd.set_starting_life_total(startingLifeTotalEdit->value());
cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked());
cmd.set_is_tournament(tournamentCheckBox->isChecked());
if (tournamentCheckBox->isChecked()) {
cmd.mutable_tournament_settings()->set_games_per_match(tournamentSettings.gamesPerMatch);
}
auto _gameTypes = QString();
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);

View file

@ -7,6 +7,8 @@
#ifndef DLG_CREATEGAME_H
#define DLG_CREATEGAME_H
#include "dlg_tournament_settings.h"
#include <QDialog>
#include <QMap>
#include <libcockatrice/utility/macros.h>
@ -48,6 +50,9 @@ private:
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
QCheckBox *shareDecklistsOnLoadCheckBox;
QCheckBox *tournamentCheckBox;
QPushButton *tournamentSettingsButton;
DlgTournamentSettingsResult tournamentSettings;
QDialogButtonBox *buttonBox;
QPushButton *clearButton;
QCheckBox *rememberGameSettings;

View file

@ -0,0 +1,48 @@
#include "dlg_tournament_settings.h"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QPushButton>
#include <QSpinBox>
DlgTournamentSettings::DlgTournamentSettings(QWidget *parent) : QDialog(parent)
{
setWindowTitle(tr("Tournament Settings"));
auto *mainLayout = new QFormLayout(this);
gamesPerMatchSpin = new QSpinBox(this);
gamesPerMatchSpin->setRange(1, 5);
gamesPerMatchSpin->setValue(1);
gamesPerMatchSpin->setToolTip(tr("Number of games per match (e.g., 3 for Best of 3)"));
mainLayout->addRow(tr("Games per match:"), gamesPerMatchSpin);
QLabel *hintLabel = new QLabel(tr("Set to 3 for Best of 3, 5 for Best of 5, etc."), this);
hintLabel->setStyleSheet("color: palette(placeholderText);");
mainLayout->addRow(QString(), hintLabel);
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTournamentSettings::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addRow(buttonBox);
setFixedHeight(sizeHint().height());
}
DlgTournamentSettingsResult DlgTournamentSettings::getResult() const
{
DlgTournamentSettingsResult result;
result.gamesPerMatch = gamesPerMatchSpin->value();
return result;
}
void DlgTournamentSettings::setCurrentGamesPerMatch(int n)
{
gamesPerMatchSpin->setValue(n);
}
void DlgTournamentSettings::actOK()
{
accept();
}

View file

@ -0,0 +1,30 @@
#ifndef DLG_TOURNAMENT_SETTINGS_H
#define DLG_TOURNAMENT_SETTINGS_H
#include <QDialog>
class QDialogButtonBox;
class QSpinBox;
struct DlgTournamentSettingsResult
{
int gamesPerMatch = 1;
};
class DlgTournamentSettings : public QDialog
{
Q_OBJECT
public:
explicit DlgTournamentSettings(QWidget *parent = nullptr);
DlgTournamentSettingsResult getResult() const;
void setCurrentGamesPerMatch(int n);
private slots:
void actOK();
private:
QSpinBox *gamesPerMatchSpin;
QDialogButtonBox *buttonBox;
};
#endif

View file

@ -0,0 +1,232 @@
#include "tournament_widget.h"
#include <QHeaderView>
#include <QLabel>
#include <QPushButton>
#include <QTableWidgetItem>
#include <QVBoxLayout>
namespace
{
const int PLAYER_COLUMN = 0;
const int DECK_COLUMN = 1;
const int WINS_COLUMN = 2;
const int LOSSES_COLUMN = 3;
const int DRAWS_COLUMN = 4;
const int PLAYER1_COLUMN = 0;
const int SCORE_COLUMN = 1;
const int STATUS_COLUMN = 2;
const int PLAYER2_COLUMN = 3;
} // namespace
TournamentWidget::TournamentWidget(QWidget *parent) : QWidget(parent)
{
auto *mainLayout = new QVBoxLayout(this);
statusLabel = new QLabel(this);
statusLabel->setStyleSheet("font-weight: bold;");
mainLayout->addWidget(statusLabel);
roundLabel = new QLabel(this);
mainLayout->addWidget(roundLabel);
standingsTable = new QTableWidget(this);
standingsTable->setColumnCount(5);
standingsTable->horizontalHeader()->setStretchLastSection(true);
standingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
standingsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
standingsTable->verticalHeader()->setVisible(false);
mainLayout->addWidget(standingsTable);
pairingsTable = new QTableWidget(this);
pairingsTable->setColumnCount(4);
pairingsTable->horizontalHeader()->setStretchLastSection(true);
pairingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
pairingsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
pairingsTable->verticalHeader()->setVisible(false);
mainLayout->addWidget(pairingsTable);
openMatchButton = new QPushButton(this);
openMatchButton->setEnabled(false);
connect(openMatchButton, &QPushButton::clicked, this, [this]() { emit openMatchGameRequested(currentGameId); });
mainLayout->addWidget(openMatchButton);
retranslateUi();
}
void TournamentWidget::retranslateUi()
{
standingsTable->setHorizontalHeaderLabels({tr("Player"), tr("Deck"), tr("W"), tr("L"), tr("D")});
pairingsTable->setHorizontalHeaderLabels({tr("Player 1"), tr("Score"), tr("Status"), tr("Player 2")});
openMatchButton->setText(tr("Open match game"));
if (lastState.has_phase()) {
updateTournamentState(lastState);
} else {
statusLabel->setText(tr("Tournament"));
roundLabel->setVisible(true);
roundLabel->setText(tr("Waiting for the tournament to start"));
}
}
void TournamentWidget::setLocalPlayerId(int playerId)
{
localPlayerId = playerId;
}
void TournamentWidget::setIsSpectator(bool spectator)
{
isSpectator = spectator;
}
QString TournamentWidget::getPlayerName(const Event_TournamentState &state, int playerId) const
{
for (int i = 0; i < state.players_size(); ++i) {
if (state.players(i).player_id() == playerId) {
return QString::fromStdString(state.players(i).player_name());
}
}
return tr("Player %1").arg(playerId);
}
void TournamentWidget::updateTournamentState(const Event_TournamentState &state)
{
lastState = state;
int gamesPerMatch = getGamesPerMatch(state);
QString statusText;
switch (state.phase()) {
case Event_TournamentState::PHASE_DECK_BUILDING:
statusText = gamesPerMatch > 1 ? tr("Tournament - Deck building (Best of %1)").arg(gamesPerMatch)
: tr("Tournament - Deck building");
break;
case Event_TournamentState::PHASE_PLAYING:
statusText = gamesPerMatch > 1 ? tr("Tournament - Playing (Best of %1)").arg(gamesPerMatch)
: tr("Tournament - Playing");
break;
case Event_TournamentState::PHASE_FINISHED:
statusText = gamesPerMatch > 1 ? tr("Tournament - Finished (Best of %1)").arg(gamesPerMatch)
: tr("Tournament - Finished");
break;
default:
statusText = tr("Tournament");
break;
}
statusLabel->setText(statusText);
// The server does not report rounds before pairing starts; avoid showing "Round 0 of 0".
bool roundStarted = state.current_round() > 0 || state.total_rounds() > 0;
roundLabel->setVisible(roundStarted);
if (roundStarted) {
roundLabel->setText(tr("Round %1 of %2").arg(state.current_round()).arg(state.total_rounds()));
}
rebuildTables(state);
resolveCurrentGameId(state);
updateOpenMatchButton();
}
void TournamentWidget::updateOpenMatchButton()
{
bool playingPhase = lastState.phase() == Event_TournamentState::PHASE_PLAYING;
openMatchButton->setVisible(playingPhase);
if (!playingPhase) {
return;
}
if (hasOwnLivePairing) {
openMatchButton->setEnabled(true);
openMatchButton->setText(tr("Open match game"));
openMatchButton->setToolTip(tr("Switch to your current match game"));
} else if (isSpectator && hasAnyLivePairing) {
// Spectators have no pairing of their own but may watch any running match.
openMatchButton->setEnabled(true);
openMatchButton->setText(tr("Spectate live match"));
openMatchButton->setToolTip(tr("Watch a match that is currently running"));
} else {
openMatchButton->setEnabled(false);
openMatchButton->setText(tr("Open match game"));
openMatchButton->setToolTip(tr("No match game is running for you right now"));
}
}
void TournamentWidget::rebuildTables(const Event_TournamentState &state)
{
standingsTable->setRowCount(state.players_size());
for (int i = 0; i < state.players_size(); ++i) {
const auto &player = state.players(i);
auto *nameItem = new QTableWidgetItem(QString::fromStdString(player.player_name()));
nameItem->setToolTip(QString::fromStdString(player.player_name()));
standingsTable->setItem(i, PLAYER_COLUMN, nameItem);
auto *deckItem = new QTableWidgetItem(player.deck_submitted() ? tr("Submitted") : tr("Pending"));
deckItem->setToolTip(player.deck_submitted() ? tr("Deck submitted") : tr("Still choosing a deck"));
standingsTable->setItem(i, DECK_COLUMN, deckItem);
standingsTable->setItem(i, WINS_COLUMN, new QTableWidgetItem(QString::number(player.wins())));
standingsTable->setItem(i, LOSSES_COLUMN, new QTableWidgetItem(QString::number(player.losses())));
standingsTable->setItem(i, DRAWS_COLUMN, new QTableWidgetItem(QString::number(player.draws())));
}
pairingsTable->setRowCount(state.pairings_size());
for (int i = 0; i < state.pairings_size(); ++i) {
const auto &pairing = state.pairings(i);
QString name1 = getPlayerName(state, pairing.player1_id());
QString name2 = pairing.player2_id() == -1 ? tr("BYE") : getPlayerName(state, pairing.player2_id());
QString scoreStr;
if (getGamesPerMatch(state) > 1 && pairing.player2_id() != -1) {
scoreStr = tr("%1 - %2").arg(pairing.player1_match_wins()).arg(pairing.player2_match_wins());
}
QString statusStr;
if (pairing.player2_id() == -1) {
statusStr = tr("BYE");
} else if (pairing.winner_id() != -1) {
statusStr = tr("Finished");
} else {
statusStr = tr("vs");
}
pairingsTable->setItem(i, PLAYER1_COLUMN, new QTableWidgetItem(name1));
pairingsTable->setItem(i, SCORE_COLUMN, new QTableWidgetItem(scoreStr));
pairingsTable->setItem(i, STATUS_COLUMN, new QTableWidgetItem(statusStr));
pairingsTable->setItem(i, PLAYER2_COLUMN, new QTableWidgetItem(name2));
}
}
int TournamentWidget::getGamesPerMatch(const Event_TournamentState &state) const
{
return state.has_settings() ? state.settings().games_per_match() : 1;
}
void TournamentWidget::resolveCurrentGameId(const Event_TournamentState &state)
{
currentGameId = -1;
hasOwnLivePairing = false;
hasAnyLivePairing = false;
for (int i = 0; i < state.pairings_size(); ++i) {
const auto &pairing = state.pairings(i);
if (pairing.game_id() == -1) {
continue;
}
if (!hasOwnLivePairing && localPlayerId != -1 &&
(pairing.player1_id() == localPlayerId || pairing.player2_id() == localPlayerId)) {
currentGameId = pairing.game_id();
hasOwnLivePairing = true;
}
if (!hasAnyLivePairing) {
hasAnyLivePairing = true;
if (!hasOwnLivePairing) {
currentGameId = pairing.game_id();
}
}
if (hasOwnLivePairing && hasAnyLivePairing) {
return;
}
}
}

View file

@ -0,0 +1,58 @@
#ifndef TOURNAMENT_WIDGET_H
#define TOURNAMENT_WIDGET_H
#include <QWidget>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
class QLabel;
class QPushButton;
class QTableWidget;
class QTableWidgetItem;
/**
* @class TournamentWidget
* @ingroup GameViews
* @brief Displays live standings and pairings for a tournament game.
*
* Updated exclusively via updateTournamentState(); the widget holds no
* logic of its own beyond mapping protocol state to table rows. Navigation
* decisions (which pairing belongs to the local player) are resolved here so
* callers only need to react to openMatchGameRequested().
*/
class TournamentWidget : public QWidget
{
Q_OBJECT
public:
explicit TournamentWidget(QWidget *parent = nullptr);
void updateTournamentState(const Event_TournamentState &state);
void setLocalPlayerId(int playerId);
void setIsSpectator(bool spectator);
void retranslateUi();
signals:
/*! Emitted when the user asks to open the match game of their current pairing. */
void openMatchGameRequested(int gameId);
private:
void rebuildTables(const Event_TournamentState &state);
void resolveCurrentGameId(const Event_TournamentState &state);
void updateOpenMatchButton();
[[nodiscard]] QString getPlayerName(const Event_TournamentState &state, int playerId) const;
[[nodiscard]] int getGamesPerMatch(const Event_TournamentState &state) const;
QLabel *statusLabel;
QLabel *roundLabel;
QTableWidget *standingsTable;
QTableWidget *pairingsTable;
QPushButton *openMatchButton;
int localPlayerId = -1;
bool isSpectator = false;
int currentGameId = -1;
bool hasOwnLivePairing = false;
bool hasAnyLivePairing = false;
Event_TournamentState lastState;
};
#endif // TOURNAMENT_WIDGET_H

View file

@ -33,6 +33,7 @@
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "tab_supervisor.h"
#include "tournament_tab_game_extension.h"
#include <QAction>
#include <QApplication>
@ -134,6 +135,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
createMenuItems();
createViewMenuItems();
if (game->getGameMetaInfo()->isTournament()) {
tournamentExtension = new TournamentTabGameExtension(this);
}
connectToGameState();
connectToPlayerManager();
connectToGameEventHandler();
@ -150,6 +155,10 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
gameTypes.append(game->getGameMetaInfo()->findRoomGameType(i));
}
if (tournamentExtension) {
tournamentExtension->initializeTournamentMode();
}
QTimer::singleShot(0, this, &TabGame::loadLayout);
}
@ -400,6 +409,10 @@ void TabGame::retranslateUi()
}
scene->retranslateUi();
if (tournamentExtension) {
tournamentExtension->retranslateUi();
}
}
void TabGame::refreshShortcuts()
@ -928,8 +941,19 @@ void TabGame::stopGame()
}
}
bool TabGame::switchToGameTab(int gameId)
{
return tabSupervisor->switchToGameTabIfAlreadyExists(gameId);
}
void TabGame::closeGame()
{
int parentId = game->getGameMetaInfo()->parentGameId();
if (parentId >= 0 && switchToGameTab(parentId)) {
close();
return;
}
gameMenu->clear();
gameMenu->addAction(aLeaveGame);
}

View file

@ -19,11 +19,13 @@
#include <QCompleter>
#include <QLoggingCategory>
#include <QMap>
#include <QPointer>
#include <QStringListModel>
class CardMenu;
class ServerInfo_PlayerProperties;
class TabbedDeckViewContainer;
class TournamentTabGameExtension;
inline Q_LOGGING_CATEGORY(TabGameLog, "tab_game");
class UserListProxy;
@ -92,6 +94,8 @@ private:
QList<QAction *> phaseActions;
QAction *aCardMenu;
QPointer<TournamentTabGameExtension> tournamentExtension;
/**
* @brief The actions associated with managing a QDockWidget
*/
@ -189,6 +193,8 @@ public:
void connectToGameEventHandler();
void connectMessageLogToGameEventHandler();
void connectPlayerListToGameEventHandler();
/*! Brings the tab of the given game to front if it is open in this session. */
bool switchToGameTab(int gameId);
TabGame(TabSupervisor *_tabSupervisor, GameReplay *replay);
~TabGame() override;
void retranslateUi() override;
@ -202,6 +208,19 @@ public:
return game;
}
[[nodiscard]] QStackedWidget *getMainWidget() const
{
return mainWidget;
}
[[nodiscard]] QVBoxLayout *getDeckViewContainerLayout() const
{
return deckViewContainerLayout;
}
[[nodiscard]] QWidget *getDeckViewContainerWidget() const
{
return deckViewContainerWidget;
}
public slots:
void viewCardInfo(const CardRef &cardRef = {}) const;
void resetChatAndPhase();

View file

@ -1007,10 +1007,20 @@ void TabSupervisor::replayLeft(TabGame *tab)
}
void TabSupervisor::joinReportGame(const int gameId, const int roomId)
{
startSpectatorJoin(gameId, roomId, tr("Report joins are only available on a remote server."));
}
void TabSupervisor::spectatorJoinGame(const int gameId, const int roomId)
{
startSpectatorJoin(gameId, roomId, tr("Spectating is only available on a remote server."));
}
void TabSupervisor::startSpectatorJoin(const int gameId, const int roomId, const QString &unavailableMessage)
{
auto *remoteClient = qobject_cast<RemoteClient *>(client);
if (!remoteClient) {
actShowPopup(tr("Report joins are only available on a remote server."));
actShowPopup(unavailableMessage);
return;
}

View file

@ -92,6 +92,8 @@ public:
};
private:
void startSpectatorJoin(int gameId, int roomId, const QString &unavailableMessage);
ServerInfo_User *userInfo;
AbstractClient *client;
UserListManager *userListManager;
@ -189,6 +191,7 @@ public slots:
TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false);
void openReplay(GameReplay *replay);
void joinReportGame(int gameId, int roomId);
void spectatorJoinGame(int gameId, int roomId);
void openTabModeration(const QString &userName = {});
void switchToFirstAvailableNetworkTab();
void maximizeMainWindow();

View file

@ -0,0 +1,167 @@
#include "tournament_tab_game_extension.h"
#include "../../../game/game_event_handler.h"
#include "../../widgets/draft/tournament_widget.h"
#include "tab_game.h"
#include "tab_supervisor.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
TournamentTabGameExtension::TournamentTabGameExtension(TabGame *parent) : QObject(parent), tabGame(parent)
{
tournamentOverviewWidget = new QWidget(parent);
auto *overviewLayout = new QVBoxLayout(tournamentOverviewWidget);
auto *headerLayout = new QHBoxLayout;
backToGameButton = new QPushButton(tournamentOverviewWidget);
connect(backToGameButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showDeckViewPage);
headerLayout->addWidget(backToGameButton);
headerLayout->addStretch();
overviewLayout->addLayout(headerLayout);
tournamentWidget = new TournamentWidget(tournamentOverviewWidget);
tournamentWidget->setLocalPlayerId(parent->getGame()->getPlayerManager()->getLocalPlayerId());
tournamentWidget->setIsSpectator(parent->getGame()->getPlayerManager()->isSpectator());
overviewLayout->addWidget(tournamentWidget);
parent->getMainWidget()->addWidget(tournamentOverviewWidget);
deckViewStatusLabel = new QLabel(parent->getDeckViewContainerWidget());
deckViewStatusLabel->setStyleSheet("color: palette(placeholderText);");
deckViewStatusLabel->setVisible(false);
standingsButton = new QPushButton(parent->getDeckViewContainerWidget());
connect(standingsButton, &QPushButton::clicked, this, &TournamentTabGameExtension::showOverviewPage);
standingsButton->setVisible(false);
connectSignals();
}
void TournamentTabGameExtension::connectSignals()
{
auto *handler = tabGame->getGame()->getGameEventHandler();
connect(handler, &GameEventHandler::tournamentStateChanged, this,
&TournamentTabGameExtension::onTournamentStateChanged);
connect(tournamentWidget, &TournamentWidget::openMatchGameRequested, this,
&TournamentTabGameExtension::openMatchGame);
}
void TournamentTabGameExtension::initializeTournamentMode()
{
if (!tabGame) {
return;
}
auto *deckLayout = tabGame->getDeckViewContainerLayout();
int index = 0;
deckLayout->insertWidget(index++, deckViewStatusLabel);
deckLayout->insertWidget(index++, standingsButton);
deckLayout->insertSpacing(index, 4);
}
void TournamentTabGameExtension::retranslateUi()
{
backToGameButton->setText(tr("Back to game view"));
standingsButton->setText(tr("Tournament standings"));
tournamentWidget->retranslateUi();
}
void TournamentTabGameExtension::updateDeckViewStrip(const Event_TournamentState &state)
{
int submitted = 0;
for (int i = 0; i < state.players_size(); ++i) {
if (state.players(i).deck_submitted()) {
++submitted;
}
}
QString phaseText;
switch (state.phase()) {
case Event_TournamentState::PHASE_DECK_BUILDING:
phaseText = tr("Deck building");
break;
case Event_TournamentState::PHASE_PLAYING:
phaseText = tr("Round in progress");
break;
case Event_TournamentState::PHASE_FINISHED:
phaseText = tr("Finished");
break;
default:
phaseText = tr("Unknown phase");
break;
}
// The submission count only matters while players are still picking decks.
QString text =
state.phase() == Event_TournamentState::PHASE_DECK_BUILDING
? tr("Tournament: %1 - %2/%3 decks submitted").arg(phaseText).arg(submitted).arg(state.players_size())
: tr("Tournament: %1").arg(phaseText);
deckViewStatusLabel->setText(text);
deckViewStatusLabel->setVisible(true);
}
void TournamentTabGameExtension::updateNavigationButtons(const Event_TournamentState &state)
{
bool showStandings =
state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED;
standingsButton->setVisible(showStandings);
}
void TournamentTabGameExtension::onTournamentStateChanged(const Event_TournamentState &state)
{
if (!tabGame) {
return;
}
updateDeckViewStrip(state);
updateNavigationButtons(state);
tournamentWidget->updateTournamentState(state);
// Switch to the standings page only when the phase itself changes, so score
// updates never yank the user away while they are looking at their deck.
// Instant switches are the app-wide baseline today; motion and sound cues
// for this transition are deferred to the Game dressing phase.
bool overviewPhase =
state.phase() == Event_TournamentState::PHASE_PLAYING || state.phase() == Event_TournamentState::PHASE_FINISHED;
if (overviewPhase && (!hasLastKnownPhase || state.phase() != lastKnownPhase)) {
tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget);
}
lastKnownPhase = state.phase();
hasLastKnownPhase = true;
}
void TournamentTabGameExtension::showOverviewPage()
{
if (tabGame) {
tabGame->getMainWidget()->setCurrentWidget(tournamentOverviewWidget);
}
}
void TournamentTabGameExtension::showDeckViewPage()
{
if (tabGame) {
tabGame->getMainWidget()->setCurrentWidget(tabGame->getDeckViewContainerWidget());
}
}
void TournamentTabGameExtension::openMatchGame(int gameId)
{
if (!tabGame || gameId <= 0) {
return;
}
// Players are auto-joined into their match game by the server, so the tab
// usually already exists and switching is enough. Otherwise (e.g. after
// leaving the match) fall back to joining as spectator.
if (tabGame->switchToGameTab(gameId)) {
return;
}
int roomId = tabGame->getGame()->getGameMetaInfo()->proto().room_id();
tabGame->getTabSupervisor()->spectatorJoinGame(gameId, roomId);
}

View file

@ -0,0 +1,54 @@
#ifndef TOURNAMENT_TAB_GAME_EXTENSION_H
#define TOURNAMENT_TAB_GAME_EXTENSION_H
#include <QObject>
#include <QPointer>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
class QLabel;
class QPushButton;
class QWidget;
class TournamentWidget;
class TabGame;
/**
* @class TournamentTabGameExtension
* @ingroup GameViews
* @brief Attaches tournament behavior to a TabGame hosting a tournament hub game.
*
* Owns the tournament overview page inside the tab's stacked main widget,
* mirrors tournament state onto the always-visible deck-view status strip,
* and routes user intents (open match game, change settings, switch pages)
* between the two views.
*/
class TournamentTabGameExtension : public QObject
{
Q_OBJECT
public:
explicit TournamentTabGameExtension(TabGame *parent);
void initializeTournamentMode();
void retranslateUi();
private slots:
void onTournamentStateChanged(const Event_TournamentState &state);
void showOverviewPage();
void showDeckViewPage();
void openMatchGame(int gameId);
private:
void connectSignals();
void updateDeckViewStrip(const Event_TournamentState &state);
void updateNavigationButtons(const Event_TournamentState &state);
QPointer<TabGame> tabGame;
TournamentWidget *tournamentWidget = nullptr;
QWidget *tournamentOverviewWidget = nullptr;
QPushButton *backToGameButton = nullptr;
QPushButton *standingsButton = nullptr;
QLabel *deckViewStatusLabel = nullptr;
Event_TournamentState::TournamentPhase lastKnownPhase = Event_TournamentState::PHASE_DECK_BUILDING;
bool hasLastKnownPhase = false;
};
#endif // TOURNAMENT_TAB_GAME_EXTENSION_H

View file

@ -14,9 +14,12 @@ set(HEADERS
game/server_deck_validation_strategy.h
game/server_game.h
game/server_game_lifecycle_strategy.h
game/server_match_result_strategy.h
game/server_match_game_factory.h
game/server_match_result_strategy.h
game/server_player.h
game/server_tournament.h
game/server_tournament_lifecycle_strategy.h
game/server_tournament_match_result_strategy.h
game/server_spectator.h
server.h
server_abstractuserinterface.h
@ -43,6 +46,9 @@ add_library(
game/server_game.cpp
game/server_player.cpp
game/server_spectator.cpp
game/server_tournament.cpp
game/server_tournament_lifecycle_strategy.cpp
game/server_tournament_match_result_strategy.cpp
server.cpp
server_abstractuserinterface.cpp
server_database_interface.cpp

View file

@ -41,6 +41,7 @@
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
#include <libcockatrice/protocol/pb/command_tournament.pb.h>
#include <libcockatrice/protocol/pb/command_undo_draw.pb.h>
#include <libcockatrice/protocol/pb/context_connection_state_changed.pb.h>
#include <libcockatrice/protocol/pb/event_game_say.pb.h>
@ -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;
}
@ -570,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);
@ -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;
}

View file

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

View file

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

View file

@ -47,6 +47,7 @@ public:
{
return deck;
}
void setDeck(DeckList *_deck);
bool getReadyStart() const
{
return readyStart;

View file

@ -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 <QDebug>
#include <QRegularExpression>
#include <QTimer>
#include <algorithm>
#include <google/protobuf/descriptor.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/context_connection_state_changed.pb.h>
@ -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,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);
}
@ -313,7 +322,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;
}
@ -567,6 +583,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()) {
@ -783,6 +810,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,
@ -861,6 +897,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 +948,92 @@ void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strat
{
deckValidationStrategy.reset(strategy);
}
void Server_Game::setMatchResultStrategy(Server_MatchResultStrategy *strategy)
{
matchResultStrategy.reset(strategy);
}
void Server_Game::setIsTournamentGame(bool _isTournament)
{
isTournament = _isTournament;
if (isTournament) {
tournament = new Server_Tournament(this, this, this);
lifecycleStrategy.reset(new Server_TournamentLifecycleStrategy);
matchResultStrategy.reset(new Server_TournamentMatchResultStrategy);
} else if (tournament) {
delete tournament;
tournament = nullptr;
lifecycleStrategy.reset(new Server_DefaultLifecycleStrategy);
matchResultStrategy.reset(new Server_NullMatchResultStrategy);
}
}
void Server_Game::startTournament()
{
if (!tournament) {
tournament = new Server_Tournament(this, this, this);
}
if (!tournament->isStarted()) {
// Add all current players to the tournament
auto players = getPlayers();
for (auto *player : players.values()) {
tournament->addPlayer(player->getPlayerId(), QString::fromStdString(player->getUserInfo()->name()));
}
// A tournament with fewer than two players can't produce a valid bracket.
if (tournament->getPlayerCount() < 2) {
qWarning() << "Cannot start tournament with fewer than 2 players";
return;
}
tournament->startTournament();
}
GameEventStorage ges;
tournament->broadcastTournamentState(ges);
ges.sendToGame(this);
}
void Server_Game::setPlayerTournamentDeck(int playerId, DeckList *deck)
{
if (tournament) {
tournament->setPlayerDeck(playerId, deck);
}
}
void Server_Game::setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id)
{
tournamentParentGame = parentGame;
tournamentMatchPlayer1Id = p1Id;
tournamentMatchPlayer2Id = p2Id;
}
Server_Game *Server_Game::createMatchGame(const GameConfig &config, int &outGameId)
{
Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface();
outGameId = databaseInterface->getNextGameId();
if (outGameId == -1) {
return nullptr;
}
GameConfig matchConfig = config;
matchConfig.gameId = outGameId;
auto *game = new Server_Game(matchConfig, room);
// Sub-games carry the tournament flag (for protocol fields) but keep the default
// strategies; the parent tournament drives them through the match result strategy
// installed by Server_Tournament::createMatchGame.
game->isTournament = true;
return game;
}
Server_AbstractUserInterface *Server_Game::getUserInterface(const QString &playerName)
{
return room->getUserInterfaceByName(playerName);
}
void Server_Game::addGameToRoom(Server_Game *game)
{
room->addGame(game);
}

View file

@ -24,31 +24,38 @@
#include "game_config.h"
#include "server_deck_validation_strategy.h"
#include "server_game_lifecycle_strategy.h"
#include "server_match_game_factory.h"
#include "server_match_result_strategy.h"
#include <QDateTime>
#include <QMap>
#include <QMutex>
#include <QObject>
#include <QPointer>
#include <QScopedPointer>
#include <QSet>
#include <QStringList>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
class QTimer;
class DeckList;
class GameEventContainer;
class GameEventStorage;
class GameReplay;
class Server_Room;
class Server_AbstractPlayer;
class Server_AbstractParticipant;
class Server_Card;
class Server_Tournament;
class ServerInfo_User;
class ServerInfo_Game;
class Server_AbstractUserInterface;
class Event_GameStateChanged;
class Server_Game : public QObject
class Server_Game : public QObject, public Server_MatchGameFactory
{
Q_OBJECT
private:
@ -83,6 +90,14 @@ private:
QList<GameReplay *> replayList;
GameReplay *currentReplay;
bool isTournament;
TournamentSettings tournamentSettings;
Server_Tournament *tournament;
QPointer<Server_Game> tournamentParentGame;
int tournamentMatchPlayer1Id;
int tournamentMatchPlayer2Id;
bool disconnectRemovesPlayer;
QScopedPointer<Server_DeckValidationStrategy> deckValidationStrategy;
QScopedPointer<Server_GameLifecycleStrategy> lifecycleStrategy;
@ -220,6 +235,49 @@ public:
void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player);
/** @brief Get the current deck validation strategy (non-owning). */
bool getIsTournamentGame() const
{
return isTournament;
}
void setIsTournamentGame(bool _isTournament);
bool getIsTournament() const
{
return tournament != nullptr;
}
Server_Tournament *getTournament() const
{
return tournament;
}
void startTournament();
void setPlayerTournamentDeck(int playerId, DeckList *deck);
void setTournamentMatchInfo(Server_Game *parentGame, int p1Id, int p2Id);
QPointer<Server_Game> getTournamentParentGame() const
{
return tournamentParentGame;
}
bool getDisconnectRemovesPlayer() const
{
return disconnectRemovesPlayer;
}
void setDisconnectRemovesPlayer(bool _disconnectRemovesPlayer)
{
disconnectRemovesPlayer = _disconnectRemovesPlayer;
}
// Server_MatchGameFactory implementation
Server_Game *createMatchGame(const GameConfig &config, int &outGameId) override;
Server_AbstractUserInterface *getUserInterface(const QString &playerName) override;
void addGameToRoom(Server_Game *game) override;
const TournamentSettings &getTournamentSettings() const
{
return tournamentSettings;
}
void setTournamentSettings(const TournamentSettings &settings)
{
tournamentSettings = settings;
}
Server_DeckValidationStrategy *getDeckValidationStrategy() const
{
return deckValidationStrategy.data();
@ -232,6 +290,8 @@ public:
{
return lifecycleStrategy.data();
}
/** @brief Replace the match result strategy; takes ownership of @p strategy. */
void setMatchResultStrategy(Server_MatchResultStrategy *strategy);
};
#endif

View file

@ -0,0 +1,691 @@
#include "server_tournament.h"
#include "../server_abstractuserinterface.h"
#include "../server_response_containers.h"
#include "../serverinfo_user_container.h"
#include "game_config.h"
#include "server_abstract_player.h"
#include "server_game.h"
#include "server_match_game_factory.h"
#include "server_player.h"
#include "server_tournament_match_result_strategy.h"
#include <QLoggingCategory>
#include <algorithm>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
inline Q_LOGGING_CATEGORY(TournamentLog, "tournament");
Server_Tournament::Server_Tournament(Server_Game *_parentGame, Server_MatchGameFactory *_factory, QObject *parent)
: QObject(parent), parentGame(_parentGame), matchGameFactory(_factory), currentRound(0), totalRounds(0),
started(false)
{
}
Server_Tournament::~Server_Tournament()
{
qDeleteAll(submittedDecks);
}
void Server_Tournament::addPlayer(int playerId, const QString &playerName)
{
QMutexLocker locker(&tournamentMutex);
TournamentPlayerData data;
data.playerId = playerId;
data.playerName = playerName;
data.dropped = false;
players[playerId] = data;
}
void Server_Tournament::setPlayerDeck(int playerId, DeckList *deck)
{
QMutexLocker locker(&tournamentMutex);
delete submittedDecks.value(playerId, nullptr);
submittedDecks[playerId] = deck;
if (players.contains(playerId)) {
players[playerId].deckSubmitted = true;
}
}
void Server_Tournament::removePlayer(int playerId)
{
QMutexLocker locker(&tournamentMutex);
players.remove(playerId);
submittedDecks.remove(playerId);
byeGivenPlayers.remove(playerId);
}
void Server_Tournament::dropPlayer(int playerId)
{
QMutexLocker locker(&tournamentMutex);
if (!players.contains(playerId)) {
return;
}
players[playerId].dropped = true;
players[playerId].deckSubmitted = false;
// Any current pairing that involves the dropped player and is not already
// decided is awarded to the surviving opponent (or recorded as undecided if
// both dropped). The opponent keeps playing without sitting out a round.
for (auto &pairing : currentPairings) {
if (pairing.winnerId != -2) {
continue;
}
bool involvesDropped = (pairing.player1Id == playerId || pairing.player2Id == playerId);
if (!involvesDropped) {
continue;
}
if (pairing.player1Id == playerId && pairing.player2Id == playerId) {
continue;
}
int opponent = (pairing.player1Id == playerId) ? pairing.player2Id : pairing.player1Id;
if (players.contains(opponent) && !players[opponent].dropped) {
pairing.winnerId = opponent;
players[opponent].wins += 1;
players[playerId].losses += 1;
}
allPreviousPairings.append(qMakePair(pairing.player1Id, pairing.player2Id));
}
}
void Server_Tournament::startTournament()
{
{
QMutexLocker locker(&tournamentMutex);
if (started) {
return;
}
totalRounds = calculateTotalRounds();
started = true;
currentRound = 0;
generateSwissPairings();
}
// Spawn the first round's match games
enqueueMatchGameCreation();
}
bool Server_Tournament::isAllDecksSubmitted() const
{
QMutexLocker locker(&tournamentMutex);
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (!it->deckSubmitted) {
return false;
}
}
return true;
}
int Server_Tournament::getTournamentPlayerIdByName(const QString &name) const
{
QMutexLocker locker(&tournamentMutex);
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (it->playerName == name) {
return it->playerId;
}
}
return -1;
}
void Server_Tournament::generateSwissPairings()
{
currentPairings.clear();
QList<int> available;
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (!it->dropped) {
available.append(it->playerId);
}
}
// Sort by wins descending (and by record for tie-breaking)
std::sort(available.begin(), available.end(), [this](int a, int b) {
const auto &pa = players[a];
const auto &pb = players[b];
if (pa.wins != pb.wins) {
return pa.wins > pb.wins;
}
if (pa.losses != pb.losses) {
return pa.losses < pb.losses;
}
return a < b;
});
QSet<int> paired;
// Try to pair every player, allowing a single rematch only if the greedy pass
// would otherwise leave any unpaired remainder. Dropped players are never paired.
int maxRematches = available.size() / 2;
for (int i = 0; i < available.size(); ++i) {
if (paired.contains(available[i])) {
continue;
}
for (int j = i + 1; j < available.size(); ++j) {
if (paired.contains(available[j])) {
continue;
}
bool rematch = havePlayed(available[i], available[j]);
if (rematch && maxRematches <= 0) {
continue;
}
TournamentPairingData pairing;
pairing.player1Id = available[i];
pairing.player2Id = available[j];
currentPairings.append(pairing);
paired.insert(available[i]);
paired.insert(available[j]);
if (rematch) {
--maxRematches;
}
break;
}
}
// Give a bye to every remaining unpaired eligible player, worst-ranked first.
// A player receives at most one bye over the whole tournament.
QList<int> unpaired;
for (int id : available) {
if (!paired.contains(id)) {
unpaired.append(id);
}
}
// Byes go to the lowest-ranked eligible player who has not had one yet.
std::sort(unpaired.begin(), unpaired.end(), [this](int a, int b) {
const auto &pa = players[a];
const auto &pb = players[b];
if (pa.wins != pb.wins) {
return pa.wins < pb.wins;
}
if (pa.losses != pb.losses) {
return pa.losses > pb.losses;
}
return a > b;
});
for (int id : unpaired) {
if (byeGivenPlayers.contains(id)) {
// Already used a bye: a dropped opponent or earlier bye means this player
// simply sits out the round with a free win to keep the bracket moving.
TournamentPairingData bye;
bye.player1Id = id;
bye.player2Id = -1;
bye.winnerId = id;
currentPairings.append(bye);
continue;
}
TournamentPairingData bye;
bye.player1Id = id;
bye.player2Id = -1;
bye.winnerId = id;
currentPairings.append(bye);
players[id].wins += 1;
byeGivenPlayers.insert(id);
allPreviousPairings.append(qMakePair(id, -1));
}
}
int Server_Tournament::calculateTotalRounds() const
{
int n = 0;
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (!it->dropped) {
++n;
}
}
if (n <= 1) {
return 0;
}
// Standard Swiss rounds: ceil(log2(n))
int rounds = 0;
while ((1 << rounds) < n) {
++rounds;
}
return rounds;
}
bool Server_Tournament::havePlayed(int p1, int p2) const
{
for (const auto &pair : allPreviousPairings) {
if ((pair.first == p1 && pair.second == p2) || (pair.first == p2 && pair.second == p1)) {
return true;
}
}
return false;
}
void Server_Tournament::advanceRound(GameEventStorage &ges)
{
{
QMutexLocker locker(&tournamentMutex);
++currentRound;
if (currentRound >= totalRounds) {
broadcastTournamentState(ges);
return;
}
generateSwissPairings();
}
enqueueMatchGameCreation();
broadcastTournamentState(ges);
}
bool Server_Tournament::allPairingsDecided() const
{
for (const auto &pairing : currentPairings) {
if (pairing.winnerId == -2) {
return false;
}
}
return true;
}
void Server_Tournament::enqueueMatchGameCreation()
{
QList<QPair<int, int>> planned;
{
QMutexLocker locker(&tournamentMutex);
for (const auto &pairing : currentPairings) {
if (pairing.player2Id == -1 || pairing.winnerId != -2) {
continue;
}
if (players.value(pairing.player1Id).dropped || players.value(pairing.player2Id).dropped) {
continue;
}
if (pairing.matchGameIds.size() >= static_cast<int>(gamesPerMatch)) {
continue;
}
planned.append(qMakePair(pairing.player1Id, pairing.player2Id));
}
}
if (planned.isEmpty()) {
return;
}
// Create the games from the event loop instead of the caller's stack: command
// processing holds game mutexes, and room registration takes gamesLock, so spawning
// synchronously would nest lock orders. The queued job runs once this object's
// owning thread returns to its event loop with no locks held; it is dropped if this
// tournament is destroyed first.
QMetaObject::invokeMethod(
this,
[this, planned] {
for (const auto &pair : planned) {
createMatchGame(pair.first, pair.second);
}
GameEventStorage ges;
broadcastTournamentState(ges);
ges.sendToGame(parentGame);
},
Qt::QueuedConnection);
}
void Server_Tournament::createMatchGame(int player1Id, int player2Id)
{
if (!matchGameFactory || player2Id == -1) {
return;
}
QString player1Name;
QString player2Name;
QString deck1Native;
QString deck2Native;
int round = 0;
int gameNumber = 1;
{
QMutexLocker locker(&tournamentMutex);
player1Name = players.value(player1Id).playerName;
player2Name = players.value(player2Id).playerName;
if (submittedDecks.contains(player1Id)) {
deck1Native = submittedDecks.value(player1Id)->writeToString_Native();
}
if (submittedDecks.contains(player2Id)) {
deck2Native = submittedDecks.value(player2Id)->writeToString_Native();
}
round = currentRound;
for (const auto &pairing : currentPairings) {
if (pairing.player1Id == player1Id && pairing.player2Id == player2Id) {
gameNumber = pairing.matchGameIds.size() + 1;
break;
}
}
// Defense in depth: never exceed the configured series length
for (const auto &pairing : currentPairings) {
if (pairing.player1Id == player1Id && pairing.player2Id == player2Id &&
pairing.matchGameIds.size() >= static_cast<int>(gamesPerMatch)) {
qCWarning(TournamentLog) << "Refusing to exceed series length for pairing" << player1Id << player2Id;
return;
}
}
// Bail out if either participant is no longer connected: a match game with
// zero or one connected player can never finish and would stall the round.
if (!matchGameFactory->getUserInterface(player1Name) || !matchGameFactory->getUserInterface(player2Name)) {
qCWarning(TournamentLog) << "Skipping match creation: a player in pairing" << player1Id << player2Id
<< "is no longer connected";
return;
}
}
// Create a sub-game for this match via the factory, copying the real
// ServerInfo_User so it ships the true user level rather than a fabricated
// admin identity that would surface in buddy/ignore-list checks.
ServerInfo_User creatorInfo;
if (auto *ui = matchGameFactory->getUserInterface(player1Name)) {
creatorInfo = *ui->getUserInfo();
} else {
creatorInfo.set_name(player1Name.toStdString());
}
QString gameDesc = gamesPerMatch > 1
? QString("R%1 Match - Game %2 of %3").arg(round).arg(gameNumber).arg(gamesPerMatch)
: QString("Tournament Round %1").arg(round);
GameConfig matchConfig;
matchConfig.creatorInfo = creatorInfo;
matchConfig.description = gameDesc;
matchConfig.maxPlayers = 2;
matchConfig.startingLifeTotal = parentGame->getStartingLifeTotal();
int matchGameId = -1;
auto *matchGame = matchGameFactory->createMatchGame(matchConfig, matchGameId);
if (!matchGame || matchGameId == -1) {
return;
}
matchGame->setTournamentMatchInfo(parentGame, player1Id, player2Id);
matchGame->setMatchResultStrategy(new Server_TournamentMatchResultStrategy);
// A disconnect inside a tournament match must remove the player so the match
// can be decided; it must not leave them sitting as a half-present participant.
matchGame->setDisconnectRemovesPlayer(true);
matchGameFactory->addGameToRoom(matchGame);
// Store the game ID in the pairing
{
QMutexLocker locker(&tournamentMutex);
for (auto &pairing : currentPairings) {
if (pairing.player1Id == player1Id && pairing.player2Id == player2Id) {
pairing.gameId = matchGameId;
pairing.matchGameIds.append(matchGameId);
break;
}
}
}
// Auto-join both players, sending the join event directly through their UIs.
// Both UI lookups were verified above, so a player can only drop between that
// check and this add — in which case they get handled by drop processing and
// the pairing settles on the surviving opponent.
QMap<int, QPair<Server_AbstractUserInterface *, ResponseContainer *>> joiners;
auto joinAndSetupPlayer = [&](int pid, const QString &name) {
Server_AbstractUserInterface *ui = matchGameFactory->getUserInterface(name);
if (ui) {
auto *rc = new ResponseContainer(0);
matchGame->addPlayer(ui, *rc, false, false, false);
joiners[pid] = qMakePair(ui, rc);
}
};
joinAndSetupPlayer(player1Id, player1Name);
joinAndSetupPlayer(player2Id, player2Name);
// Now send the enqueued GameJoined + GameStateChanged events to each player's client.
for (auto it = joiners.constBegin(); it != joiners.constEnd(); ++it) {
it.value().first->sendResponseContainer(*it.value().second, Response::RespNothing);
delete it.value().second;
}
joiners.clear();
// Set decks and mark players as ready in the match game.
bool anyDeckMissing = false;
auto matchPlayers = matchGame->getPlayers();
for (auto *matchPlayer : matchPlayers) {
const QString name = QString::fromStdString(matchPlayer->getUserInfo()->name());
QString deckNative;
if (name == player1Name) {
deckNative = deck1Native;
} else if (name == player2Name) {
deckNative = deck2Native;
}
if (!deckNative.isEmpty()) {
matchPlayer->setDeck(new DeckList(deckNative));
matchPlayer->setReadyStart(true);
} else {
anyDeckMissing = true;
}
}
if (anyDeckMissing) {
// Not every participant submitted a deck. Do not force-start: that would
// kick the players without a deck. Leave the match game open so they can
// select a deck; the host starts it through the normal ready flow.
return;
}
// Start the match game without forcing: both participants are ready and have
// decks, so there is nothing to kick.
matchGame->startGameIfReady(false);
}
void Server_Tournament::recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges)
{
QMutexLocker locker(&tournamentMutex);
// Find the pairing and set the winner
for (auto &pairing : currentPairings) {
if ((pairing.player1Id == playerId1 && pairing.player2Id == playerId2) ||
(pairing.player1Id == playerId2 && pairing.player2Id == playerId1)) {
if (pairing.winnerId != -2) {
return; // Already recorded — defense in depth against double-call
}
pairing.winnerId = winnerId;
break;
}
}
// Update player records
if (winnerId == -1) {
// Draw
players[playerId1].draws += 1;
players[playerId2].draws += 1;
} else if (winnerId == playerId1) {
players[playerId1].wins += 1;
players[playerId2].losses += 1;
} else if (winnerId == playerId2) {
players[playerId2].wins += 1;
players[playerId1].losses += 1;
}
// Store for future pairing avoidance
allPreviousPairings.append(qMakePair(playerId1, playerId2));
// Check if all pairings in current round have results
bool allDecided = true;
for (const auto &pairing : currentPairings) {
if (pairing.winnerId == -2) {
allDecided = false;
break;
}
}
broadcastTournamentState(ges);
if (allDecided) {
advanceRound(ges);
}
}
bool Server_Tournament::recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges)
{
bool matchDecided = false;
bool seriesContinues = false;
int p1 = -1;
int p2 = -1;
{
QMutexLocker locker(&tournamentMutex);
// Find the pairing that owns this game
TournamentPairingData *pairingPtr = nullptr;
for (auto &pairing : currentPairings) {
if (pairing.matchGameIds.contains(gameId)) {
pairingPtr = &pairing;
break;
}
}
if (!pairingPtr) {
return false;
}
// If the match is already decided, ignore further sub-game results
if (pairingPtr->winnerId != -2) {
return true;
}
// Increment per-match wins
if (winnerId == pairingPtr->player1Id) {
pairingPtr->player1MatchWins += 1;
} else if (winnerId == pairingPtr->player2Id) {
pairingPtr->player2MatchWins += 1;
}
// Draw (winnerId == -1): counts nothing toward the series but does consume
// a slot, so a series can still end in a draw when it is exhausted.
// The winner needs a strict majority of the games in the series.
const int gamesPlayed = pairingPtr->matchGameIds.size();
const int gamesNeeded = static_cast<int>(gamesPerMatch / 2 + 1);
const int gamesRemaining = static_cast<int>(gamesPerMatch) - gamesPlayed;
matchDecided = (pairingPtr->player1MatchWins >= gamesNeeded) || (pairingPtr->player2MatchWins >= gamesNeeded);
if (!matchDecided) {
// Series exhausted without a strict-majority winner (e.g. a drawn Bo3
// leaves it 1-1): record the match as a draw so the round always advances.
matchDecided = (gamesRemaining <= 0) && (pairingPtr->player1MatchWins == pairingPtr->player2MatchWins);
}
if (matchDecided) {
// Determine match winner
int matchWinnerId = -1;
if (pairingPtr->player1MatchWins >= gamesNeeded) {
matchWinnerId = pairingPtr->player1Id;
} else if (pairingPtr->player2MatchWins >= gamesNeeded) {
matchWinnerId = pairingPtr->player2Id;
}
// Otherwise the series was exhausted evenly — matchWinnerId stays -1 (a draw).
// Set the match winner on the pairing
pairingPtr->winnerId = matchWinnerId;
// Update tournament-level player records
if (matchWinnerId == pairingPtr->player1Id) {
players[pairingPtr->player1Id].wins += 1;
players[pairingPtr->player2Id].losses += 1;
} else if (matchWinnerId == pairingPtr->player2Id) {
players[pairingPtr->player2Id].wins += 1;
players[pairingPtr->player1Id].losses += 1;
} else {
players[pairingPtr->player1Id].draws += 1;
players[pairingPtr->player2Id].draws += 1;
}
// Store for future pairing avoidance
allPreviousPairings.append(qMakePair(pairingPtr->player1Id, pairingPtr->player2Id));
} else {
// Match not decided — spawn the next sub-game outside all locks
seriesContinues = true;
p1 = pairingPtr->player1Id;
p2 = pairingPtr->player2Id;
}
broadcastTournamentState(ges);
}
if (seriesContinues) {
QMetaObject::invokeMethod(
this,
[this, p1, p2] {
createMatchGame(p1, p2);
GameEventStorage nextGes;
broadcastTournamentState(nextGes);
nextGes.sendToGame(parentGame);
},
Qt::QueuedConnection);
}
checkAndAdvanceRound(ges);
return matchDecided;
}
void Server_Tournament::checkAndAdvanceRound(GameEventStorage &ges)
{
bool roundComplete = false;
{
QMutexLocker locker(&tournamentMutex);
roundComplete = allPairingsDecided();
}
if (roundComplete) {
advanceRound(ges);
}
}
Event_TournamentState Server_Tournament::buildStateEvent() const
{
QMutexLocker locker(&tournamentMutex);
Event_TournamentState state;
if (started && currentRound >= totalRounds) {
state.set_phase(Event_TournamentState::PHASE_FINISHED);
} else if (started) {
state.set_phase(Event_TournamentState::PHASE_PLAYING);
} else {
state.set_phase(Event_TournamentState::PHASE_DECK_BUILDING);
}
state.set_current_round(currentRound);
state.set_total_rounds(totalRounds);
// Settings
TournamentSettings *settings = state.mutable_settings();
settings->set_games_per_match(gamesPerMatch);
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
TournamentPlayer *p = state.add_players();
p->set_player_id(it->playerId);
p->set_player_name(it->playerName.toStdString());
p->set_wins(it->wins);
p->set_losses(it->losses);
p->set_draws(it->draws);
p->set_deck_submitted(it->deckSubmitted);
}
for (const auto &pairing : currentPairings) {
TournamentPairing *p = state.add_pairings();
p->set_player1_id(pairing.player1Id);
p->set_player2_id(pairing.player2Id);
p->set_game_id(pairing.gameId);
// -2 = undecided; a decided draw is -1. The is_draw bit distinguishes a
// reported draw from an unset winner_id on the wire.
if (pairing.winnerId == -1) {
p->set_is_draw(true);
} else if (pairing.winnerId != -2) {
p->set_winner_id(pairing.winnerId);
}
p->set_player1_match_wins(pairing.player1MatchWins);
p->set_player2_match_wins(pairing.player2MatchWins);
}
return state;
}
void Server_Tournament::broadcastTournamentState(GameEventStorage &ges)
{
ges.enqueueGameEvent(buildStateEvent(), -1);
}

View file

@ -0,0 +1,116 @@
#ifndef SERVER_TOURNAMENT_H
#define SERVER_TOURNAMENT_H
#include <QList>
#include <QMap>
#include <QObject>
#include <QPointer>
#include <QRecursiveMutex>
#include <QSet>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
class DeckList;
class Server_Game;
class Server_MatchGameFactory;
class Server_AbstractParticipant;
class Server_AbstractUserInterface;
class GameEventStorage;
/** @brief Maximum number of games per match a tournament can be configured with. */
constexpr int MAX_GAMES_PER_MATCH = 5;
class Server_Tournament : public QObject
{
Q_OBJECT
public:
explicit Server_Tournament(Server_Game *_parentGame, Server_MatchGameFactory *_factory, QObject *parent = nullptr);
~Server_Tournament() override;
void addPlayer(int playerId, const QString &playerName);
void removePlayer(int playerId);
// Marks an already-starting/started tournament player as dropped: they stop
// being paired and their outstanding unstarted match is awarded as a loss.
void dropPlayer(int playerId);
void startTournament();
void advanceRound(GameEventStorage &ges);
void recordMatchResult(int playerId1, int playerId2, int winnerId, GameEventStorage &ges);
bool recordMatchResultByGameId(int gameId, int winnerId, GameEventStorage &ges);
void broadcastTournamentState(GameEventStorage &ges);
// Current tournament state message, for replaying to a participant joining late.
Event_TournamentState buildStateEvent() const;
bool isStarted() const
{
return started;
}
bool isAllDecksSubmitted() const;
int getPlayerCount() const
{
return players.size();
}
int getTournamentPlayerIdByName(const QString &name) const;
void setPlayerDeckSubmitted(int playerId)
{
if (players.contains(playerId)) {
players[playerId].deckSubmitted = true;
}
}
void setPlayerDeck(int playerId, DeckList *deck);
void setGamesPerMatch(uint32_t n)
{
gamesPerMatch = n;
}
uint32_t getGamesPerMatch() const
{
return gamesPerMatch;
}
struct TournamentPlayerData
{
int playerId;
QString playerName;
int wins = 0;
int losses = 0;
int draws = 0;
bool deckSubmitted = false;
bool dropped = false;
};
struct TournamentPairingData
{
int player1Id;
int player2Id;
int gameId = -1;
int winnerId = -2; // -2 = undecided, -1 = draw, >= 0 = winner player id
int player1MatchWins = 0;
int player2MatchWins = 0;
QList<int> matchGameIds;
};
private:
QPointer<Server_Game> parentGame;
Server_MatchGameFactory *matchGameFactory;
mutable QRecursiveMutex tournamentMutex;
QMap<int, TournamentPlayerData> players;
QMap<int, DeckList *> submittedDecks;
QList<TournamentPairingData> currentPairings;
// Players that have already received a bye in a previous round, so no one
// gets more than one bye over the whole tournament.
QSet<int> byeGivenPlayers;
QList<QPair<int, int>> allPreviousPairings;
int currentRound;
int totalRounds;
bool started;
uint32_t gamesPerMatch = 1;
void generateSwissPairings();
int calculateTotalRounds() const;
bool havePlayed(int p1, int p2) const;
bool allPairingsDecided() const;
void createMatchGame(int player1Id, int player2Id);
void enqueueMatchGameCreation();
void checkAndAdvanceRound(GameEventStorage &ges);
};
#endif // SERVER_TOURNAMENT_H

View file

@ -0,0 +1,33 @@
#include "server_tournament_lifecycle_strategy.h"
#include "server_abstract_player.h"
#include "server_game.h"
#include <QLoggingCategory>
inline Q_LOGGING_CATEGORY(TournamentLifecycleLog, "tournament_lifecycle");
Server_GameLifecycleStrategy::StartAction Server_TournamentLifecycleStrategy::onGameStarting(Server_Game *game)
{
// Match sub-games start through the normal flow; only the tournament hub game is
// managed by this lifecycle.
if (game->getTournamentParentGame().data() != nullptr) {
return StartAction::ProceedNormal;
}
for (auto *player : game->getPlayers().values()) {
if (!player->getDeckList()) {
qCWarning(TournamentLifecycleLog)
<< "Tournament cannot start: player" << player->getUserInfo()->name().c_str() << "has no deck";
return StartAction::Handled;
}
}
if (!game->getIsTournamentGame()) {
qCWarning(TournamentLifecycleLog) << "Tournament lifecycle used for non-tournament game — falling back";
return StartAction::ProceedNormal;
}
game->startTournament();
return StartAction::Handled;
}

View file

@ -0,0 +1,12 @@
#ifndef SERVER_TOURNAMENT_LIFECYCLE_STRATEGY_H
#define SERVER_TOURNAMENT_LIFECYCLE_STRATEGY_H
#include "server_game_lifecycle_strategy.h"
class Server_TournamentLifecycleStrategy : public Server_GameLifecycleStrategy
{
public:
StartAction onGameStarting(Server_Game *game) override;
};
#endif

View file

@ -0,0 +1,37 @@
#include "server_tournament_match_result_strategy.h"
#include "../server_response_containers.h"
#include "server_abstract_player.h"
#include "server_game.h"
#include "server_tournament.h"
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
#include <libcockatrice/protocol/pb/event_tournament_state.pb.h>
bool Server_TournamentMatchResultStrategy::onGameFinished(Server_Game *game,
int playing,
Server_AbstractPlayer *lastPlayer)
{
// The hub game is owned by the room and may be torn down once its host leaves
// and no players remain, while the match sub-games keep running. QPointer keeps
// this link checked so a later-finishing match can't touch freed memory.
auto *parentGame = game->getTournamentParentGame().data();
if (!parentGame || !parentGame->getTournament()) {
return false;
}
int winnerId;
if (playing == 0) {
winnerId = -1;
} else {
QString winnerName = QString::fromStdString(lastPlayer->getUserInfo()->name());
auto *tournament = parentGame->getTournament();
winnerId = tournament->getTournamentPlayerIdByName(winnerName);
}
GameEventStorage parentGes;
bool matchDecided = parentGame->getTournament()->recordMatchResultByGameId(game->getGameId(), winnerId, parentGes);
parentGes.sendToGame(parentGame);
return matchDecided;
}

View file

@ -0,0 +1,12 @@
#ifndef SERVER_TOURNAMENT_MATCH_RESULT_STRATEGY_H
#define SERVER_TOURNAMENT_MATCH_RESULT_STRATEGY_H
#include "server_match_result_strategy.h"
class Server_TournamentMatchResultStrategy : public Server_MatchResultStrategy
{
public:
bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) override;
};
#endif

View file

@ -3,6 +3,7 @@
#include "game/game_config.h"
#include "game/server_game.h"
#include "game/server_player.h"
#include "game/server_tournament.h"
#include "server_database_interface.h"
#include "server_room.h"
@ -916,6 +917,9 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20;
bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false;
bool isTournament = cmd.has_is_tournament() ? cmd.is_tournament() : false;
int gamesPerMatch =
cmd.has_tournament_settings() ? static_cast<int>(cmd.tournament_settings().games_per_match()) : 1;
const int gameId = databaseInterface->getNextGameId();
if (gameId == -1) {
@ -940,6 +944,10 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
.shareDecklistsOnLoad = shareDecklistsOnLoad};
auto *game = new Server_Game(config, room);
game->setIsTournamentGame(isTournament);
if (isTournament && game->getTournament()) {
game->getTournament()->setGamesPerMatch(static_cast<uint32_t>(qBound(1, gamesPerMatch, MAX_GAMES_PER_MATCH)));
}
game->addPlayer(this, rc, asSpectator, asJudge, false);
room->addGame(game);

View file

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

View file

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

View file

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

View file

@ -0,0 +1,31 @@
syntax = "proto2";
import "game_commands.proto";
import "serverinfo_tournament.proto";
message Command_ReportMatchResult {
extend GameCommand {
optional Command_ReportMatchResult ext = 1037;
}
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 {
extend GameCommand {
optional Command_AdvanceTournament ext = 1038;
}
}
message Command_TournamentSettingsSelect {
extend GameCommand {
optional Command_TournamentSettingsSelect ext = 1039;
}
optional TournamentSettings settings = 1;
}

View file

@ -24,4 +24,7 @@ message Event_GameStateChanged {
// the amount of seconds since the game started
optional uint32 seconds_elapsed = 5;
// 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];
}

View file

@ -0,0 +1,22 @@
syntax = "proto2";
import "game_event.proto";
import "serverinfo_tournament.proto";
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;
}

View file

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

View file

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

View file

@ -1,4 +1,5 @@
syntax = "proto2";
import "serverinfo_tournament.proto";
message RoomCommand {
enum RoomCommandType {
LEAVE_ROOM = 1000;
@ -69,6 +70,12 @@ message Command_CreateGame {
// share decklists with all players when selected
optional bool share_decklists_on_load = 14;
// 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;
}
message Command_JoinGame {

View file

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

View file

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