[Server/Client/Protocol] Shuffle or reorder player seats before a game starts (#7398)

* [Protocol] Add shuffle players option and player order command/event

Adds a 'Shuffle players at start' option to game creation plus a local
player id on the game state so clients can reconcile seat changes, and a
SET_PLAYER_ORDER command (1206) with a PLAYER_ORDER_CHANGED event (2023)
so the game host can set or randomize the seating order.

Took 6 minutes

* [Server] Shuffle and reorder player seats before a game starts

Auto-shuffles the seats at game start when enabled (now for games with
two or more players instead of three or more) and handles the new
SET_PLAYER_ORDER command so the game host or a moderator can reorder or
randomize the seats before the game starts. Re-seating re-keys the
player ids while keeping the per-user game/seat routing and persistent
player records in sync, then broadcasts the new order to everyone.

* [Client] Add shuffle players option and turn order dialog

Adds the 'Shuffle players at start' checkbox to game creation and a
host-only 'Set turn order...' action that opens a dialog to reorder or
randomize the seats before the game starts. Seating changes are shown in
the game's message log.

* [Client] Move the turn order dialog strings into retranslateUi

Follows the DlgInviteToGame convention so the dialog can be retranslated
instead of freezing its strings at construction time.

* [Server] Keep player ids stable across seat shuffles

* [Client] Lay out table and turn order dialog in seat order

* [Server] Send game-joined player list in seat order

* [Client] Reword the shuffle option to match its behaviour

* [Client] Address the seat-order review comments

- Give the game setup checkboxes a row of their own so they no longer
  collide in the create-game dialog
- Rename DlgTurnOrder::randomize() to isRandomizeRequested()
- Document what the ids in GameState::seatOrder mean
- Keep "Set turn order..." in sync with the game: hidden for non-hosts
  and once the game has started, re-shown when the game ends or hosting
  moves to the local player (new GameState::hostChanged signal)

* [Client] Do not preview a randomized turn order

Randomizing is a request to the server, not a local shuffle. Shuffling the
list locally meant the dialog showed an order the server then discarded, and
let the host re-open it until a favourable order appeared. The dialog now
switches to a mode that says the server will randomize and toggles back to the
manual list on request.

* [Client] Let moderators set the turn order

The server accepts SET_PLAYER_ORDER from the host or a moderator, but the
client hid the action from everyone except the host, making the moderator
branch unreachable. Visibility and the handler now share canSetTurnOrder().

* [Server] Rely on the player order event instead of a full state resend

cmdSetPlayerOrder re-sent the whole game state after reseating, so every
client reloaded its deck view mid-lobby for no reason. The new seating reaches
clients through the Event_PlayerOrderChanged that reseatPlayers broadcasts.

* [Client] Show random turn order in the game selector

The restrictions column listed "open decklists" but not the new seating
option, so a joining player had no way to see that seats will be randomized
each game. Game info already shows the disabled checkbox.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-11-04 15:44:50 +01:00 • committed by GitHub
parent ca2301bc96
commit 5ea16c805f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1054 additions and 15 deletions

View file

@ -56,6 +56,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_turn_order.cpp
src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp

View file

@ -36,6 +36,7 @@
#include <functional>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/arrow_data.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_event_handler.h>
#include <libcockatrice/game/player/player_info.h>
@ -441,6 +442,37 @@ QList<PlayerLogic *> GameScene::collectActivePlayers(int &firstPlayerIndex) cons
}
}
}
// Lay out the table in seat order rather than id order. Players missing
// from the tracked order keep id order at the end.
QList<int> seatOrder;
for (auto *pgItem : playerViews.values()) {
if (PlayerLogic *p = pgItem->getLogic()) {
seatOrder = p->getGame()->getGameState()->getSeatOrder();
break;
}
}
if (!seatOrder.isEmpty()) {
std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) {
int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId());
int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId());
if (indexA == -1) {
indexA = seatOrder.size() + a->getPlayerInfo()->getId();
}
if (indexB == -1) {
indexB = seatOrder.size() + b->getPlayerInfo()->getId();
}
return indexA < indexB;
});
// Recompute the local-player index after sorting.
firstPlayerIndex = 0;
for (int i = 0; i < activePlayers.size(); ++i) {
if (activePlayers[i]->getPlayerInfo()->getLocal()) {
firstPlayerIndex = i;
break;
}
}
}
return activePlayers;
}

View file

@ -605,6 +605,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed)
.arg(reversed ? tr("reversed") : tr("normal")));
}
void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized)
{
QStringList sanitizedNames;
for (const QString &name : playerNames) {
sanitizedNames.append(sanitizeHtml(name));
}
const QString order = sanitizedNames.join(" -> ");
if (randomized) {
appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order));
} else {
appendHtmlServerMessage(tr("New turn order set: %1.").arg(order));
}
}
void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls)
{
if (rolls.length() == 1) {

View file

@ -94,6 +94,7 @@ public slots:
int amount,
bool isLentToAnotherPlayer);
void logReverseTurn(PlayerLogic *player, bool reversed);
void logTurnOrderChanged(QStringList playerNames, bool randomized);
void logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls);
void logSay(PlayerLogic *player, QString message);
void logSetActivePhase(int phase);

View file

@ -116,14 +116,17 @@ void DlgCreateGame::sharedCtor()
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start"));
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(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2);
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0);
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
} else {
createGameAsJudgeCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setHidden(true);
@ -184,6 +187,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameType
createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator());
startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal());
shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad());
shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers());
if (!rememberGameSettings->isChecked()) {
actReset();
@ -217,6 +221,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
createGameAsSpectatorCheckBox->setEnabled(false);
startingLifeTotalEdit->setEnabled(false);
shareDecklistsOnLoadCheckBox->setEnabled(false);
shufflePlayersCheckBox->setEnabled(false);
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
maxPlayersEdit->setValue(gameInfo.max_players());
@ -227,6 +232,7 @@ 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());
shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
@ -264,6 +270,7 @@ void DlgCreateGame::actReset()
startingLifeTotalEdit->setValue(20);
shareDecklistsOnLoadCheckBox->setChecked(false);
shufflePlayersCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setChecked(false);
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -294,6 +301,7 @@ 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_shuffle_players(shufflePlayersCheckBox->isChecked());
auto _gameTypes = QString();
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -318,6 +326,7 @@ void DlgCreateGame::actOK()
SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked());
SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value());
SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked());
SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked());
SettingsCache::instance().game().setGameTypes(_gameTypes);
}
PendingCommand *pend = room->prepareRoomCommand(cmd);

View file

@ -51,6 +51,7 @@ private:
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
QCheckBox *shareDecklistsOnLoadCheckBox;
QCheckBox *shufflePlayersCheckBox;
QDialogButtonBox *buttonBox;
QPushButton *clearButton;
QCheckBox *rememberGameSettings;

View file

@ -0,0 +1,136 @@
#include "dlg_turn_order.h"
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPushButton>
#include <QVBoxLayout>
DlgTurnOrder::DlgTurnOrder(const QStringList &_playerNames, QWidget *parent)
: QDialog(parent), randomizeRequested(false)
{
listWidget = new QListWidget(this);
listWidget->addItems(_playerNames);
listWidget->setCurrentRow(0);
connect(listWidget, &QListWidget::currentRowChanged, this, &DlgTurnOrder::selectionChanged);
moveUpButton = new QPushButton(this);
connect(moveUpButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveUp);
moveDownButton = new QPushButton(this);
connect(moveDownButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveDown);
randomizeButton = new QPushButton(this);
connect(randomizeButton, &QPushButton::clicked, this, &DlgTurnOrder::actRandomize);
// Shown in place of the list while the server is asked to randomize: the
// order is chosen server side, so there is nothing to preview here.
hintLabel = new QLabel(this);
hintLabel->setWordWrap(true);
hintLabel->setAlignment(Qt::AlignCenter);
auto *buttonColumn = new QVBoxLayout;
buttonColumn->addWidget(moveUpButton);
buttonColumn->addWidget(moveDownButton);
buttonColumn->addWidget(randomizeButton);
buttonColumn->addStretch();
auto *listLayout = new QHBoxLayout;
listLayout->addWidget(listWidget);
listLayout->addWidget(hintLabel, 1);
listLayout->addLayout(buttonColumn);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTurnOrder::actOk);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgTurnOrder::reject);
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(listLayout);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
updateButtons();
updateMode();
setMinimumWidth(300);
}
void DlgTurnOrder::retranslateUi()
{
setWindowTitle(tr("Set turn order"));
moveUpButton->setText(tr("Move up"));
moveDownButton->setText(tr("Move down"));
randomizeButton->setText(randomizeRequested ? tr("Set order manually") : tr("Randomize"));
hintLabel->setText(tr("The server will randomize the turn order."));
}
QStringList DlgTurnOrder::order() const
{
QStringList playerNames;
for (int i = 0; i < listWidget->count(); ++i) {
playerNames.append(listWidget->item(i)->text());
}
return playerNames;
}
void DlgTurnOrder::moveSelected(int delta)
{
const int row = listWidget->currentRow();
const int newRow = row + delta;
if (row < 0 || newRow < 0 || newRow >= listWidget->count()) {
return;
}
QListWidgetItem *item = listWidget->takeItem(row);
listWidget->insertItem(newRow, item);
listWidget->setCurrentRow(newRow);
updateButtons();
}
void DlgTurnOrder::updateMode()
{
listWidget->setVisible(!randomizeRequested);
hintLabel->setVisible(randomizeRequested);
moveUpButton->setVisible(!randomizeRequested);
moveDownButton->setVisible(!randomizeRequested);
retranslateUi();
}
void DlgTurnOrder::updateButtons()
{
const int row = listWidget->currentRow();
const int count = listWidget->count();
const bool manualOrder = !randomizeRequested;
moveUpButton->setEnabled(manualOrder && row > 0);
moveDownButton->setEnabled(manualOrder && row >= 0 && row < count - 1);
}
void DlgTurnOrder::actMoveUp()
{
moveSelected(-1);
}
void DlgTurnOrder::actMoveDown()
{
moveSelected(1);
}
void DlgTurnOrder::actRandomize()
{
// Randomizing is a request to the server, not a local shuffle: showing a
// preview would let the host re-open the dialog until the order looks
// favourable for them.
randomizeRequested = !randomizeRequested;
updateMode();
updateButtons();
}
void DlgTurnOrder::actOk()
{
accept();
}
void DlgTurnOrder::selectionChanged()
{
updateButtons();
}

View file

@ -0,0 +1,51 @@
/**
* @file dlg_turn_order.h
* @ingroup GameDialogs
*/
//! \todo Document this file.
#ifndef DLG_TURN_ORDER_H
#define DLG_TURN_ORDER_H
#include <QDialog>
#include <QStringList>
class QLabel;
class QListWidget;
class QPushButton;
class DlgTurnOrder : public QDialog
{
Q_OBJECT
private:
QListWidget *listWidget;
QLabel *hintLabel;
QPushButton *moveUpButton;
QPushButton *moveDownButton;
QPushButton *randomizeButton;
bool randomizeRequested;
void moveSelected(int delta);
void updateMode();
void updateButtons();
private slots:
void actMoveUp();
void actMoveDown();
void actRandomize();
void actOk();
void selectionChanged();
public:
explicit DlgTurnOrder(const QStringList &_playerNames, QWidget *parent = nullptr);
void retranslateUi();
[[nodiscard]] bool isRandomizeRequested() const
{
return randomizeRequested;
}
[[nodiscard]] QStringList order() const;
};
#endif

View file

@ -185,6 +185,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const
if (gameentry.share_decklists_on_load()) {
result.append(tr("open decklists"));
}
if (gameentry.shuffle_players()) {
result.append(tr("random turn order"));
}
return result.join(", ");
}
case Qt::DecorationRole: {

View file

@ -20,6 +20,7 @@
#include "../interface/widgets/cards/card_info_frame_widget.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/dialogs/dlg_invite_to_game.h"
#include "../interface/widgets/dialogs/dlg_turn_order.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/utility/completer_utils.h"
@ -195,6 +196,7 @@ void TabGame::connectToGameState()
{
connect(game->getGameState(), &GameState::gameStarted, this, &TabGame::startGame);
connect(game->getGameState(), &GameState::gameStopped, this, &TabGame::stopGame);
connect(game->getGameState(), &GameState::hostChanged, this, &TabGame::updateTurnOrderAction);
connect(game->getGameState(), &GameState::activePhaseChanged, this, &TabGame::setActivePhase);
connect(game->getGameState(), &GameState::activePlayerChanged, this, &TabGame::setActivePlayer);
}
@ -263,6 +265,8 @@ void TabGame::connectMessageLogToGameEventHandler()
connect(game->getGameEventHandler(), &GameEventHandler::logTurnReversed, messageLog,
&MessageLogWidget::logReverseTurn);
connect(game->getGameEventHandler(), &GameEventHandler::logTurnOrderChanged, messageLog,
&MessageLogWidget::logTurnOrderChanged);
connect(game->getGameEventHandler(), &GameEventHandler::logConcede, messageLog, &MessageLogWidget::logConcede);
connect(game->getGameEventHandler(), &GameEventHandler::logUnconcede, messageLog, &MessageLogWidget::logUnconcede);
@ -373,6 +377,9 @@ void TabGame::retranslateUi()
if (aReverseTurn) {
aReverseTurn->setText(tr("Reverse turn order"));
}
if (aTurnOrder) {
aTurnOrder->setText(tr("&Set turn order..."));
}
if (aRemoveLocalArrows) {
aRemoveLocalArrows->setText(tr("&Remove all local arrows"));
}
@ -629,6 +636,73 @@ void TabGame::actConcede()
}
}
void TabGame::actTurnOrder()
{
if (!canSetTurnOrder()) {
return;
}
QStringList playerNames;
const QList<int> seatOrder = game->getGameState()->getSeatOrder();
const auto &players = game->getPlayerManager()->getPlayers();
if (!seatOrder.isEmpty()) {
for (int playerId : seatOrder) {
if (PlayerLogic *player = players.value(playerId, nullptr)) {
playerNames.append(player->getPlayerInfo()->getName());
}
}
// Include any players missing from the tracked order rather than dropping them.
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
const QString name = it.value()->getPlayerInfo()->getName();
if (!playerNames.contains(name)) {
playerNames.append(name);
}
}
} else {
for (PlayerLogic *player : players) {
playerNames.append(player->getPlayerInfo()->getName());
}
}
DlgTurnOrder dlg(playerNames, this);
if (dlg.exec() != QDialog::Accepted) {
return;
}
game->getGameEventHandler()->handleSetPlayerOrder(dlg.order(), dlg.isRandomizeRequested());
}
/**
* Whether the local user may change the seating order: only while the game has
* not started, and only as the host or a moderator - the server allows both
* (Server_AbstractParticipant::cmdSetPlayerOrder).
*/
bool TabGame::canSetTurnOrder() const
{
if (game->getGameMetaInfo()->started()) {
return false;
}
if (game->isHost()) {
return true;
}
const ServerInfo_User *userInfo = tabSupervisor->getUserInfo();
return userInfo != nullptr && (userInfo->user_level() & ServerInfo_User::IsModerator);
}
/**
* Shows the turn order action only when it can actually change anything: the
* player must be allowed to set the order and the game must not have started.
* Called whenever either condition may have changed (game start/stop, host
* change).
*/
void TabGame::updateTurnOrderAction()
{
if (aTurnOrder == nullptr) {
return;
}
aTurnOrder->setVisible(canSetTurnOrder());
}
/**
* Confirms the leave game and sends the leave game command, if applicable.
*
@ -840,6 +914,15 @@ void TabGame::processPlayerLeave(PlayerLogic *leavingPlayer)
}
}
// A shuffled local player has their seat changed, so the deck view container
// keyed by their previous id must be cleaned up alongside the leaving player.
int leavingPlayerId = leavingPlayer->getPlayerInfo()->getId();
if (deckViewContainers.contains(leavingPlayerId)) {
TabbedDeckViewContainer *deckView = deckViewContainers.take(leavingPlayerId);
deckViewContainerLayout->removeWidget(deckView);
deckView->deleteLater();
}
scene->removePlayer(leavingPlayer);
}
@ -948,6 +1031,7 @@ void TabGame::startGame(bool _resuming)
aConcede->setText(tr("&Concede"));
aConcede->setEnabled(true);
}
updateTurnOrderAction();
}
void TabGame::stopGame()
@ -970,6 +1054,7 @@ void TabGame::stopGame()
aConcede->setText(tr("&Concede"));
aConcede->setEnabled(false);
}
updateTurnOrderAction();
}
void TabGame::closeGame()
@ -1094,6 +1179,9 @@ void TabGame::createMenuItems()
connect(aNextTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleNextTurn);
aReverseTurn = new QAction(this);
connect(aReverseTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleReverseTurn);
aTurnOrder = new QAction(this);
connect(aTurnOrder, &QAction::triggered, this, &TabGame::actTurnOrder);
updateTurnOrderAction();
aRemoveLocalArrows = new QAction(this);
connect(aRemoveLocalArrows, &QAction::triggered, this, &TabGame::actRemoveLocalArrows);
aRotateViewCW = new QAction(this);
@ -1144,6 +1232,7 @@ void TabGame::createMenuItems()
gameMenu->addAction(aNextTurn);
aNextTurn->setObjectName("aNextTurn");
gameMenu->addAction(aReverseTurn);
gameMenu->addAction(aTurnOrder);
gameMenu->addSeparator();
gameMenu->addAction(aRemoveLocalArrows);
gameMenu->addAction(aRotateViewCW);
@ -1172,6 +1261,7 @@ void TabGame::createReplayMenuItems()
aNextPhaseAction = nullptr;
aNextTurn = nullptr;
aReverseTurn = nullptr;
aTurnOrder = nullptr;
aRemoveLocalArrows = nullptr;
aRotateViewCW = nullptr;
aRotateViewCCW = nullptr;
@ -1302,6 +1392,7 @@ void TabGame::createPlayAreaWidget(bool bReplay)
scene = new GameScene(phasesToolbar, this);
connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange);
connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, scene, &GameScene::rearrange);
connect(game->getGameState(), &GameState::seatOrderChanged, scene, &GameScene::rearrange);
connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(),
&GameEventHandler::handleArrowDeletion);
connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow);

View file

@ -94,7 +94,8 @@ private:
QMenu *gameMenu, *viewMenu;
TearOffMenu *phasesMenu;
QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn,
*aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout;
*aReverseTurn, *aTurnOrder, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout,
*aResetReplayLayout;
QAction *aFocusChat;
QAction *aInviteToGame = nullptr;
QList<QAction *> phaseActions;
@ -129,6 +130,7 @@ private:
PlayerLogic *setActivePlayer(int id);
void setActivePhase(int phase);
bool canSetTurnOrder() const;
void createMenuItems();
void createReplayMenuItems();
void createViewMenuItems();
@ -162,6 +164,8 @@ private slots:
void actInviteToGame();
void actConcede();
void actCopyGameLink();
void actTurnOrder();
void updateTurnOrderAction();
void actRemoveLocalArrows();
void actRotateViewCW();
void actRotateViewCCW();

View file

@ -21,6 +21,7 @@
#include <QMap>
#include <QMapIterator>
#include <QMessageLogger>
#include <QSet>
#include <QSharedPointer>
#include <google/protobuf/descriptor.h>
#include <google/protobuf/message.h>
@ -38,6 +39,7 @@
#include <libcockatrice/protocol/pb/command_reverse_turn.pb.h>
#include <libcockatrice/protocol/pb/command_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/command_set_player_order.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
@ -50,6 +52,7 @@
#include <libcockatrice/protocol/pb/event_join.pb.h>
#include <libcockatrice/protocol/pb/event_kicked.pb.h>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_player_order_changed.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_reverse_turn.pb.h>
#include <libcockatrice/protocol/pb/event_set_active_phase.pb.h>
@ -189,6 +192,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont,
case GameEvent::REVERSE_TURN:
eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context);
break;
case GameEvent::PLAYER_ORDER_CHANGED:
eventPlayerOrderChanged(event.GetExtension(Event_PlayerOrderChanged::ext), playerId, context);
break;
default: {
PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0);
@ -215,6 +221,16 @@ void GameEventHandler::handleReverseTurn()
sendGameCommand(Command_ReverseTurn());
}
void GameEventHandler::handleSetPlayerOrder(const QList<QString> &playerNames, bool randomize)
{
Command_SetPlayerOrder command;
command.set_randomize(randomize);
for (const QString &name : playerNames) {
command.add_player_names(name.toStdString());
}
sendGameCommand(command);
}
void GameEventHandler::handleActiveLocalPlayerConceded()
{
sendGameCommand(Command_Concede());
@ -377,6 +393,27 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
{
const int playerListSize = event.player_list_size();
if (event.has_local_player_id()) {
game->getPlayerManager()->setLocalPlayerId(event.local_player_id());
}
QSet<int> authoritativePlayerIds;
for (int i = 0; i < playerListSize; ++i) {
const ServerInfo_PlayerProperties &prop = event.player_list(i).properties();
if (!prop.spectator()) {
authoritativePlayerIds.insert(prop.player_id());
}
}
// Players whose seats were shuffled away from the previous game are no longer
// part of the authoritative list and must be removed.
for (int stalePlayerId : game->getPlayerManager()->getPlayers().keys()) {
if (!authoritativePlayerIds.contains(stalePlayerId)) {
game->getPlayerManager()->removePlayer(stalePlayerId);
emit playerLeft(stalePlayerId);
}
}
QVector<QPair<int, QPair<QString, QString>>> opponentDecksToDisplay;
for (int i = 0; i < playerListSize; ++i) {
@ -421,6 +458,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
emit remotePlayersDecksSelected(opponentDecksToDisplay);
// The server emits player_list in seat order, so record it to lay out the
// table by seating rather than by player id.
QList<int> seatOrder;
for (int i = 0; i < playerListSize; ++i) {
const ServerInfo_PlayerProperties &prop = event.player_list(i).properties();
if (!prop.spectator()) {
seatOrder.append(prop.player_id());
}
}
if (!seatOrder.isEmpty()) {
game->getGameState()->setSeatOrder(seatOrder);
}
game->getGameState()->setGameTime(event.seconds_elapsed());
if (event.game_started() && !game->getGameMetaInfo()->started()) {
@ -625,6 +675,33 @@ void GameEventHandler::eventReverseTurn(const Event_ReverseTurn &event,
emit logTurnReversed(player, event.reversed());
}
void GameEventHandler::eventPlayerOrderChanged(const Event_PlayerOrderChanged &event,
int /*eventPlayerId*/,
const GameEventContext & /*context*/)
{
QStringList playerNames;
for (const auto &name : event.player_names()) {
playerNames.append(QString::fromStdString(name));
}
// Track the seating by stable player id so the table layout and the turn
// order dialog follow the shuffled order instead of id order.
QList<int> seatOrder;
const auto &players = game->getPlayerManager()->getPlayers();
for (const QString &name : playerNames) {
for (auto it = players.constBegin(); it != players.constEnd(); ++it) {
if (it.value() && it.value()->getPlayerInfo()->getName() == name) {
seatOrder.append(it.key());
break;
}
}
}
if (!seatOrder.isEmpty()) {
game->getGameState()->setSeatOrder(seatOrder);
}
emit logTurnOrderChanged(playerNames, event.randomized());
}
void GameEventHandler::eventGameHostChanged(const Event_GameHostChanged & /*event*/,
int eventPlayerId,
const GameEventContext & /*context*/)

View file

@ -51,6 +51,7 @@ class Event_GameSay;
class Event_Kicked;
class Event_ReverseTurn;
class Event_Ping;
class Event_PlayerOrderChanged;
class ServerInfo_PlayerProperties;
namespace google
{
@ -113,6 +114,16 @@ public:
/** @brief Request reversing the current turn order. */
void handleReverseTurn();
/**
* @brief Request a new seating / turn order.
*
* Only the game host (or a moderator) may do this before the game starts.
*
* @param playerNames Ordered list of player names. Ignored when randomize is true.
* @param randomize Ask the server to shuffle the seats instead.
*/
void handleSetPlayerOrder(const QList<QString> &playerNames, bool randomize);
/** @brief Concede the game for the currently active local player. */
void handleActiveLocalPlayerConceded();
@ -316,6 +327,10 @@ public:
/** @brief Handle a turn reversal event. */
void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle a change of the seating / turn order. */
void
eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle ping / latency updates. */
void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context);
@ -443,6 +458,7 @@ signals:
void logLeave(PlayerLogic *player, QString reason);
void logKicked();
void logTurnReversed(PlayerLogic *player, bool reversed);
void logTurnOrderChanged(QStringList playerNames, bool randomized);
void logGameClosed();
void logActivePlayer(PlayerLogic *activePlayer);
void logActivePhaseChanged(int activePhase);

View file

@ -33,7 +33,11 @@ public:
void setHostId(int _hostId)
{
if (hostId == _hostId) {
return;
}
hostId = _hostId;
emit hostChanged(hostId);
}
QList<AbstractClient *> getClients() const
@ -112,6 +116,19 @@ public:
return hostId;
}
void setSeatOrder(const QList<int> &order)
{
if (seatOrder != order) {
seatOrder = order;
emit seatOrderChanged(seatOrder);
}
}
QList<int> getSeatOrder() const
{
return seatOrder;
}
int getSecondsElapsed() const
{
return secondsElapsed;
@ -123,6 +140,8 @@ signals:
void gameStopped();
void activePhaseChanged(int activePhase);
void activePlayerChanged(int playerId);
void hostChanged(int hostId);
void seatOrderChanged(const QList<int> &seatOrder);
public slots:
void incrementGameTime();
@ -142,6 +161,9 @@ private:
int currentPhase;
int activePlayer;
bool gameClosed;
//! Player ids in seating (turn) order, first to play first. Empty until the
//! server sends an order, in which case players are laid out in id order.
QList<int> seatOrder;
};
#endif // COCKATRICE_GAME_STATE_H

View file

@ -57,6 +57,11 @@ public:
return localPlayerId;
}
void setLocalPlayerId(int _localPlayerId)
{
localPlayerId = _localPlayerId;
}
[[nodiscard]] const QMap<int, PlayerLogic *> &getPlayers() const
{
return players;

View file

@ -20,6 +20,7 @@ public:
[[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0;
[[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0;
[[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0;
[[nodiscard]] virtual bool getShufflePlayers() const = 0;
[[nodiscard]] virtual bool getRememberGameSettings() const = 0;
[[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0;
[[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0;

View file

@ -21,6 +21,7 @@ struct GameConfig
bool spectatorsSeeEverything = true;
int startingLifeTotal = 20;
bool shareDecklistsOnLoad = true;
bool shufflePlayers = false;
};
#endif

View file

@ -43,6 +43,7 @@
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_player_order.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
@ -434,6 +435,33 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_
return Response::RespOk;
}
Response::ResponseCode Server_AbstractParticipant::cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd,
ResponseContainer & /*rc*/,
GameEventStorage & /*ges*/)
{
if ((game->getHostId() != playerId) && !(userInfo->user_level() & ServerInfo_User::IsModerator)) {
return Response::RespFunctionNotAllowed;
}
if (game->getGameStarted()) {
return Response::RespContextError;
}
if (cmd.randomize()) {
game->shufflePlayerSeats();
} else {
QList<QString> playerNames;
for (int i = 0; i < cmd.player_names_size(); ++i) {
playerNames.append(QString::fromStdString(cmd.player_names(i)));
}
game->reorderPlayerSeats(playerNames);
}
// Clients learn the new seating from the Event_PlayerOrderChanged that
// reseatPlayers broadcasts; no full game state resend is needed for it.
return Response::RespOk;
}
Response::ResponseCode
Server_AbstractParticipant::processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges)
{
@ -543,6 +571,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo
case GameCommand::SET_PLAYMAT:
return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges);
break;
case GameCommand::SET_PLAYER_ORDER:
return cmdSetPlayerOrder(command.GetExtension(Command_SetPlayerOrder::ext), rc, ges);
break;
default:
return Response::RespInvalidCommand;
}

View file

@ -53,6 +53,7 @@ class Command_DeckSelect;
class Command_SetSideboardLock;
class Command_ChangeZoneProperties;
class Command_SetPlaymat;
class Command_SetPlayerOrder;
class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container
{
@ -89,6 +90,10 @@ public:
{
return playerId;
}
void setPlayerId(int _playerId)
{
playerId = _playerId;
}
bool isSpectator() const
{
return roleInfo.isSpectator;
@ -173,6 +178,8 @@ public:
virtual Response::ResponseCode
cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges);
virtual Response::ResponseCode
cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges);
Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges);

View file

@ -73,11 +73,13 @@
#include <libcockatrice/protocol/pb/event_join.pb.h>
#include <libcockatrice/protocol/pb/event_kicked.pb.h>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
#include <libcockatrice/protocol/pb/event_player_order_changed.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_replay_added.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/game_replay.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <libcockatrice/utility/zone_names.h>
#include <qlogging.h>
#include <qnamespace.h>
@ -91,8 +93,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword),
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),
shufflePlayers(config.shufflePlayers), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0),
firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy),
gameMutex()
@ -292,6 +294,32 @@ int Server_Game::getSpectatorCount() const
return result;
}
QList<Server_AbstractParticipant *> Server_Game::getParticipantsInSeatOrder() const
{
// Players in seat order first (fall back to id order for seats predating
// seatOrder), then spectators. Player ids are stable; only the ordering
// changes on shuffle/reorder.
QList<Server_AbstractParticipant *> ordered;
ordered.reserve(participants.size());
for (int id : seatOrder) {
auto *participant = participants.value(id, nullptr);
if (participant && !participant->isSpectator() && !ordered.contains(participant)) {
ordered.append(participant);
}
}
for (auto *participant : participants.values()) {
if (!participant->isSpectator() && !ordered.contains(participant)) {
ordered.append(participant);
}
}
for (auto *participant : participants.values()) {
if (participant->isSpectator()) {
ordered.append(participant);
}
}
return ordered;
}
void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event,
Server_AbstractParticipant *recipient,
bool omniscient,
@ -306,16 +334,24 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event,
event->set_game_started(false);
}
for (Server_AbstractParticipant *participant : participants.values()) {
// Emit the player list in seat order so every client (including old ones
// that ignore Event_PlayerOrderChanged) sees the current seating. Player
// ids are stable; only this ordering changes on shuffle/reorder.
QList<Server_AbstractParticipant *> ordered = getParticipantsInSeatOrder();
for (Server_AbstractParticipant *participant : ordered) {
participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo);
}
if (recipient != nullptr) {
event->set_local_player_id(recipient->getPlayerId());
}
}
void Server_Game::sendGameStateToPlayers()
{
// game state information for replay and omniscient spectators
Event_GameStateChanged omniscientEvent;
createGameStateChangedEvent(&omniscientEvent, nullptr, true, false);
createGameStateChangedEvent(&omniscientEvent, nullptr, true, true);
GameEventContainer *replayCont = prepareGameEvent(omniscientEvent, -1);
replayCont->set_seconds_elapsed(secondsElapsed - startTimeOfThisGame);
@ -327,7 +363,7 @@ void Server_Game::sendGameStateToPlayers()
// the data we used for the replay. All spectators are equal, so we don't need to make a createGameStateChangedEvent
// call for each one.
Event_GameStateChanged spectatorNormalEvent;
createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, false);
createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, true);
// send game state info to clients according to their role in the game
for (auto *participant : participants.values()) {
@ -340,7 +376,7 @@ void Server_Game::sendGameStateToPlayers()
}
} else {
Event_GameStateChanged event;
createGameStateChangedEvent(&event, participant, participant->isJudge(), false);
createGameStateChangedEvent(&event, participant, participant->isJudge(), true);
gec = prepareGameEvent(event, -1);
}
@ -380,6 +416,11 @@ void Server_Game::doStartGameIfReady(bool forceStartGame)
return;
}
if (shufflePlayers && players.size() > 1) {
shufflePlayerSeats();
players = getPlayers();
}
for (Server_AbstractPlayer *player : players.values()) {
player->setupZones();
}
@ -433,6 +474,116 @@ void Server_Game::startGameIfReady(bool forceStartGame)
emit sigStartGameIfReady(forceStartGame);
}
void Server_Game::shufflePlayerSeats()
{
QMutexLocker locker(&gameMutex);
// Collect players in current seat order (fall back to id order for seats
// predating seatOrder). Spectators keep their seats.
QList<Server_AbstractParticipant *> players;
for (int id : seatOrder) {
auto *participant = participants.value(id, nullptr);
if (participant && !participant->isSpectator()) {
players.append(participant);
}
}
for (auto *participant : participants.values()) {
if (!participant->isSpectator() && !players.contains(participant)) {
players.append(participant);
}
}
QList<Server_AbstractParticipant *> spectators;
for (auto *participant : participants.values()) {
if (participant->isSpectator()) {
spectators.append(participant);
}
}
// Fisher-Yates shuffle of the players.
QList<Server_AbstractParticipant *> shuffledPlayers;
while (!players.isEmpty()) {
const int index = rng->rand(0, players.size() - 1);
shuffledPlayers.append(players.takeAt(index));
}
reseatPlayers(shuffledPlayers + spectators, true);
}
void Server_Game::reorderPlayerSeats(const QList<QString> &orderedNames)
{
QMutexLocker locker(&gameMutex);
// Collect players in current seat order (fall back to id order for seats
// predating seatOrder). Spectators keep their seats.
QList<Server_AbstractParticipant *> players;
for (int id : seatOrder) {
auto *participant = participants.value(id, nullptr);
if (participant && !participant->isSpectator()) {
players.append(participant);
}
}
for (auto *participant : participants.values()) {
if (!participant->isSpectator() && !players.contains(participant)) {
players.append(participant);
}
}
QList<Server_AbstractParticipant *> spectators;
for (auto *participant : participants.values()) {
if (participant->isSpectator()) {
spectators.append(participant);
}
}
// Order the players according to the given names, appending any player that
// was not listed (should not happen) in their current seat order.
QList<Server_AbstractParticipant *> orderedPlayers;
QSet<Server_AbstractParticipant *> placed;
for (const QString &name : orderedNames) {
for (auto *participant : players) {
if (!placed.contains(participant) && (QString::fromStdString(participant->getUserInfo()->name()) == name)) {
orderedPlayers.append(participant);
placed.insert(participant);
break;
}
}
}
for (auto *participant : players) {
if (!placed.contains(participant)) {
orderedPlayers.append(participant);
}
}
reseatPlayers(orderedPlayers + spectators, false);
}
void Server_Game::reseatPlayers(const QList<Server_AbstractParticipant *> &orderedParticipants, bool randomized)
{
QMutexLocker locker(&gameMutex);
// Keep player ids stable and only reorder the seating/turn order. Changing
// ids would break old clients (stale localPlayerId from Event_GameJoined
// duplicates their own seat as an opponent), invalidate the home-server
// per-user game/seat mapping used for ISL command routing, and orphan the
// host id. All of those maps stay valid because ids never change here.
// The current host therefore keeps its id and needs no re-key tracking.
seatOrder.clear();
for (auto *participant : orderedParticipants) {
if (!participant->isSpectator()) {
seatOrder.append(participant->getPlayerId());
}
}
// Broadcast the new seating order so that every client can display it.
Event_PlayerOrderChanged event;
for (auto *participant : orderedParticipants) {
if (!participant->isSpectator()) {
event.add_player_names(QString::fromStdString(participant->getUserInfo()->name()).toStdString());
}
}
event.set_randomized(randomized);
sendGameEventContainer(prepareGameEvent(event, -1));
}
void Server_Game::stopGameIfFinished()
{
QMutexLocker locker(&gameMutex);
@ -563,6 +714,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
allSpectatorsEver.insert(playerName);
} else {
allPlayersEver.insert(playerName);
seatOrder.append(newParticipant->getPlayerId());
// if the original creator of the game joins, give them host status back
//! \todo Transferring host to spectators has side effects.
@ -592,6 +744,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve
room->getServer()->removePersistentPlayer(QString::fromStdString(participant->getUserInfo()->name()), room->getId(),
gameId, participant->getPlayerId());
participants.remove(participant->getPlayerId());
seatOrder.removeAll(participant->getPlayerId());
bool spectator = participant->isSpectator();
GameEventStorage ges;
@ -611,10 +764,20 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve
participant->prepareDestroy();
if (playerHost) {
// Pass host to the next player in seat order so the game always keeps
// a host while players remain.
int newHostId = -1;
for (auto *otherPlayer : getPlayers().values()) {
newHostId = otherPlayer->getPlayerId();
break;
for (int id : seatOrder) {
if (participants.contains(id)) {
newHostId = id;
break;
}
}
if (newHostId == -1) {
for (auto *otherPlayer : getPlayers().values()) {
newHostId = otherPlayer->getPlayerId();
break;
}
}
if (newHostId != -1) {
hostId = newHostId;
@ -771,7 +934,20 @@ void Server_Game::nextTurn()
}
auto players = getPlayers();
const QList<int> keys = players.keys();
// Advance in seat order, not id order, so shuffles/reorders change the
// turn sequence while ids stay stable.
QList<int> keys;
keys.reserve(players.size());
for (int id : seatOrder) {
if (players.contains(id)) {
keys.append(id);
}
}
for (int id : players.keys()) {
if (!keys.contains(id)) {
keys.append(id);
}
}
int listPos = -1;
if (activePlayer != -1) {
listPos = keys.indexOf(activePlayer);
@ -821,7 +997,7 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
event2.set_active_phase(activePhase);
bool omniscient = (joiningParticipant->isSpectator() && spectatorsSeeEverything) || joiningParticipant->isJudge();
for (auto *participant : participants.values()) {
for (auto *participant : getParticipantsInSeatOrder()) {
participant->getInfo(event2.add_player_list(), joiningParticipant, omniscient, true);
}
@ -950,6 +1126,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const
result.set_spectators_can_chat(spectatorsCanTalk);
result.set_spectators_omniscient(spectatorsSeeEverything);
result.set_share_decklists_on_load(shareDecklistsOnLoad);
result.set_shuffle_players(shufflePlayers);
result.set_spectators_count(getSpectatorCount());
result.set_start_time(startTime.toSecsSinceEpoch());
}

View file

@ -74,6 +74,11 @@ private:
int hostId;
ServerInfo_User *creatorInfo;
QMap<int, Server_AbstractParticipant *> participants;
// Seating/turn order as an ordered list of player ids. Player ids themselves
// are stable for the lifetime of the participant; shuffling or reordering
// only permutes this list so old clients, ISL routes and persistent
// references (all keyed by player id) keep working.
QList<int> seatOrder;
QSet<QString> allPlayersEver, allSpectatorsEver;
bool gameStarted;
bool gameClosed;
@ -90,6 +95,7 @@ private:
bool spectatorsSeeEverything;
int startingLifeTotal;
bool shareDecklistsOnLoad;
bool shufflePlayers;
int inactivityCounter;
int startTimeOfThisGame, secondsElapsed;
bool firstGameStarted;
@ -109,6 +115,8 @@ private:
Server_AbstractParticipant *recipient,
bool omniscient,
bool withUserInfo);
QList<Server_AbstractParticipant *> getParticipantsInSeatOrder() const;
void reseatPlayers(const QList<Server_AbstractParticipant *> &orderedParticipants, bool randomized);
void storeGameInformation();
bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const;
signals:
@ -149,6 +157,10 @@ public:
{
return participants;
}
QList<int> getSeatOrder() const
{
return seatOrder;
}
int getGameId() const
{
return gameId;
@ -189,6 +201,10 @@ public:
{
return shareDecklistsOnLoad;
}
bool getShufflePlayers() const
{
return shufflePlayers;
}
Response::ResponseCode
checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions);
bool containsUser(const QString &userName) const;
@ -202,6 +218,8 @@ public:
bool kickParticipant(int playerId);
void startGameIfReady(bool forceStartGame);
void stopGameIfFinished();
void shufflePlayerSeats();
void reorderPlayerSeats(const QList<QString> &orderedNames);
int getActivePlayer() const
{
return activePlayer;

View file

@ -1116,6 +1116,7 @@ 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 shufflePlayers = cmd.has_shuffle_players() ? cmd.shuffle_players() : false;
const int gameId = databaseInterface->getNextGameId();
if (gameId == -1) {
@ -1137,7 +1138,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
.spectatorsCanTalk = cmd.spectators_can_talk(),
.spectatorsSeeEverything = cmd.spectators_see_everything(),
.startingLifeTotal = startingLifeTotal,
.shareDecklistsOnLoad = shareDecklistsOnLoad};
.shareDecklistsOnLoad = shareDecklistsOnLoad,
.shufflePlayers = shufflePlayers};
auto *game = new Server_Game(config, room);

View file

@ -66,6 +66,7 @@ set(PROTO_FILES
command_set_card_attr.proto
command_set_card_counter.proto
command_set_counter.proto
command_set_player_order.proto
command_set_playmat.proto
command_set_sideboard_lock.proto
command_set_sideboard_plan.proto
@ -110,6 +111,7 @@ set(PROTO_FILES
event_list_rooms.proto
event_move_card.proto
event_notify_user.proto
event_player_order_changed.proto
event_player_properties_changed.proto
event_remove_from_list.proto
event_remove_messages.proto

View file

@ -0,0 +1,15 @@
syntax = "proto2";
import "game_commands.proto";
message Command_SetPlayerOrder {
extend GameCommand {
optional Command_SetPlayerOrder ext = 1206;
}
// The desired seating order, expressed as player names. Only used when
// randomize is false.
repeated string player_names = 1;
// When set, the server shuffles the seating order instead of using
// player_names.
optional bool randomize = 2;
}

View file

@ -24,4 +24,9 @@ message Event_GameStateChanged {
// the amount of seconds since the game started
optional uint32 seconds_elapsed = 5;
// the id of the player this event is addressed to. Used to re-identify the
// local player after seats have been shuffled. Only set for events sent to
// a specific participant, never for omniscient/replay events.
optional sint32 local_player_id = 6;
}

View file

@ -0,0 +1,13 @@
syntax = "proto2";
import "game_event.proto";
message Event_PlayerOrderChanged {
extend GameEvent {
optional Event_PlayerOrderChanged ext = 2023;
}
// The new seating order, expressed as player names.
repeated string player_names = 1;
// Whether the order was obtained by randomizing rather than set explicitly.
optional bool randomized = 2;
}

View file

@ -180,6 +180,11 @@ message GameCommand {
/// Server: Server_Player::cmdSetPlaymat
/// Client: reflected via player properties changed event
SET_PLAYMAT = 1035;
/// Set the seating (turn) order of the players.
/// Server: Server_AbstractParticipant::cmdSetPlayerOrder
/// Client: reflected via Event_PlayerOrderChanged
SET_PLAYER_ORDER = 1206;
}
extensions 100 to max;

View file

@ -34,6 +34,7 @@ message GameEvent {
CHANGE_ZONE_PROPERTIES = 2020;
REVERSE_TURN = 2021;
GAME_LOG_NOTICE = 2022;
PLAYER_ORDER_CHANGED = 2023;
}
optional sint32 player_id = 1 [default = -1];
extensions 100 to max;

View file

@ -78,6 +78,9 @@ message Command_CreateGame {
// share decklists with all players when selected
optional bool share_decklists_on_load = 14;
// shuffle the player order (seats) before each game starts
optional bool shuffle_players = 15;
}
message Command_JoinGame {

View file

@ -48,6 +48,9 @@ message ServerInfo_Game {
// decklists are sent to all players when loaded
optional bool share_decklists_on_load = 15;
// players are shuffled into new seats before each game starts
optional bool shuffle_players = 16;
// the current player count
optional uint32 player_count = 30;

View file

@ -71,6 +71,11 @@ bool GameSettings::getShareDecklistsOnLoad() const
return getValue("shareDecklistsOnLoad", "game").toBool();
}
bool GameSettings::getShufflePlayers() const
{
return getValue("shuffleplayers", "game").toBool();
}
bool GameSettings::getRememberGameSettings() const
{
return getValue("rememberGameSettings", "game", QString(), true).toBool();
@ -151,6 +156,11 @@ void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad)
setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game");
}
void GameSettings::setShufflePlayers(bool _shufflePlayers)
{
setValue(_shufflePlayers, "shuffleplayers", "game");
}
void GameSettings::setRememberGameSettings(bool _rememberGameSettings)
{
setValue(_rememberGameSettings, "rememberGameSettings", "game");

View file

@ -27,6 +27,7 @@ public:
[[nodiscard]] bool getCreateGameAsSpectator() const override;
[[nodiscard]] int getDefaultStartingLifeTotal() const override;
[[nodiscard]] bool getShareDecklistsOnLoad() const override;
[[nodiscard]] bool getShufflePlayers() const override;
[[nodiscard]] bool getRememberGameSettings() const override;
[[nodiscard]] bool getLocalGameRememberSettings() const override;
[[nodiscard]] int getLocalGameMaxPlayers() const override;
@ -44,6 +45,7 @@ public:
void setCreateGameAsSpectator(bool _createGameAsSpectator);
void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal);
void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad);
void setShufflePlayers(bool _shufflePlayers);
void setRememberGameSettings(bool _rememberGameSettings);
void setLocalGameRememberSettings(bool value);
void setLocalGameMaxPlayers(int value);

View file

@ -9,3 +9,15 @@ cockatrice_add_test(
LABELS
server
)
cockatrice_add_test(
NAME
player_order_test
SOURCES
player_order_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)

View file

@ -0,0 +1,268 @@
#include "game/game_config.h"
#include "game/role_info.h"
#include "game/server_abstract_participant.h"
#include "game/server_game.h"
#include "server_abstractuserinterface.h"
#include "server_response_containers.h"
#include "server_room.h"
#include "server_test_helpers.h"
#include <gtest/gtest.h>
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_event.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server
class ScriptedRng : public RNG_Abstract
{
public:
QList<unsigned int> values;
unsigned int rand(int min, int max) override
{
const unsigned int value = values.takeFirst();
EXPECT_GE(value, static_cast<unsigned int>(min));
EXPECT_LE(value, static_cast<unsigned int>(max));
return value;
}
};
class FakeUserInterface : public Server_AbstractUserInterface
{
public:
FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server)
{
setUserInfo(_userInfo);
}
int getLastCommandTime() const override
{
return 0;
}
bool addSaidMessageSize(int) override
{
return true;
}
void sendProtocolItem(const Response &) override
{
}
void sendProtocolItem(const SessionEvent &) override
{
}
void sendProtocolItem(const GameEventContainer &) override
{
}
void sendProtocolItem(const RoomEvent &) override
{
}
};
namespace
{
QStringList participantNamesInOrder(const Server_Game &game)
{
QStringList names;
for (int id : game.getSeatOrder()) {
const auto *participant = game.getParticipants().value(id, nullptr);
if (participant) {
names.append(QString::fromStdString(participant->getUserInfo()->name()));
}
}
return names;
}
QList<int> participantIdsInOrder(const Server_Game &game)
{
return game.getSeatOrder();
}
} // namespace
class PlayerOrderTest : public ::testing::Test
{
protected:
ServerInfo_User creator;
ServerInfo_User userB;
ServerInfo_User userC;
PlayerOrderTest()
{
creator.set_name("A");
creator.set_user_level(ServerInfo_User::IsRegistered);
userB.set_name("B");
userB.set_user_level(ServerInfo_User::IsRegistered);
userC.set_name("C");
userC.set_user_level(ServerInfo_User::IsRegistered);
}
};
TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({0, 1, 2}));
EXPECT_EQ(game.getHostId(), 0);
EXPECT_EQ(game.getPlayerCount(), 3);
}
TEST_F(PlayerOrderTest, ReorderPlayerSeats)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
game.reorderPlayerSeats({"C", "A", "B"});
// player ids are stable; only the seat order changes
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
EXPECT_EQ(game.getParticipants().size(), 3);
// the host keeps its seat: creator A is still in the game, so host stays 0
EXPECT_EQ(game.getHostId(), 0);
// no re-keying means every mapping keyed by player id stays valid
const int gameId = game.getGameId();
EXPECT_EQ(uiA.getGames().value(gameId).second, 0);
EXPECT_EQ(uiB.getGames().value(gameId).second, 1);
EXPECT_EQ(uiC.getGames().value(gameId).second, 2);
}
TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
// listing only a subset appends the remaining players in their current order
game.reorderPlayerSeats({"C"});
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
EXPECT_EQ(game.getPlayerCount(), 3);
EXPECT_EQ(game.getHostId(), 0);
}
TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation)
{
FakeServer server;
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
FakeUserInterface uiA(&server, creator);
FakeUserInterface uiB(&server, userB);
FakeUserInterface uiC(&server, userC);
GameConfig config{.creatorInfo = creator,
.gameId = 1,
.description = QString(),
.password = QString(),
.maxPlayers = 3,
.gameTypes = QList<int>(),
.onlyBuddies = false,
.onlyRegistered = false,
.spectatorsAllowed = false,
.spectatorsNeedPassword = false,
.spectatorsCanTalk = false,
.spectatorsSeeEverything = false,
.startingLifeTotal = 20,
.shareDecklistsOnLoad = false,
.shufflePlayers = false};
Server_Game game(config, &room);
ResponseContainer rc(0);
game.addPlayer(&uiA, rc, RoleInfo{}, false);
game.addPlayer(&uiB, rc, RoleInfo{}, false);
game.addPlayer(&uiC, rc, RoleInfo{}, false);
// fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C)
auto *scriptedRng = new ScriptedRng;
scriptedRng->values = {1, 0, 0};
rng = scriptedRng;
game.shufflePlayerSeats();
EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"}));
EXPECT_EQ(participantIdsInOrder(game), QList<int>({1, 0, 2}));
QSet<int> idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend());
EXPECT_EQ(idSet.size(), 3); // ids are unique
// ids are stable, so the host (creator A, id 0) is unchanged
EXPECT_EQ(game.getHostId(), 0);
}