[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

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