feat(command-zone): add server-side support with tests

Implement server-side protocol and game logic for networked command
zone play.

Protocol changes:
- room_commands.proto: command zone messages and responses

Server components:
- ServerCounter: counter synchronization for commander tax
- ServerGame: command zone game state management
- ServerPlayer: player-level command zone handling
- Server/ServerProtocolHandler: command zone message routing
- Servatrice: command zone support in the main server

Design decisions:
- setCount() returns bool for event suppression (avoid duplicate events)
- Zone state synchronized across all connected clients
- Commander tax persists across zone transitions

Test coverage verifies setCount() return value behavior for proper
event suppression in networked scenarios.
This commit is contained in:
DawnFire42 2026-02-25 13:38:59 -05:00
parent 47fade7bcb
commit c2a10466db
13 changed files with 457 additions and 22 deletions

View file

@ -54,7 +54,7 @@ add_library(
add_dependencies(libcockatrice_network_server_remote libcockatrice_protocol)
target_include_directories(libcockatrice_network_server_remote PUBLIC .)
target_include_directories(libcockatrice_network_server_remote PUBLIC . ${CMAKE_CURRENT_SOURCE_DIR}/../../../../)
# Make cockatrice_server depend on cockatrice_protocol
target_link_libraries(

View file

@ -2,8 +2,15 @@
#include <libcockatrice/protocol/pb/serverinfo_counter.pb.h>
Server_Counter::Server_Counter(int _id, const QString &_name, const color &_counterColor, int _radius, int _count)
: id(_id), name(_name), counterColor(_counterColor), radius(_radius), count(_count)
Server_Counter::Server_Counter(int _id,
const QString &_name,
const color &_counterColor,
int _radius,
int _count,
int _minValue,
int _maxValue)
: id(_id), name(_name), counterColor(_counterColor), radius(_radius), count(qBound(_minValue, _count, _maxValue)),
minValue(_minValue), maxValue(_maxValue)
{
}

View file

@ -22,6 +22,7 @@
#include <QString>
#include <libcockatrice/protocol/pb/color.pb.h>
#include <limits>
class ServerInfo_Counter;
@ -33,9 +34,20 @@ protected:
color counterColor;
int radius;
int count;
int minValue;
int maxValue;
// Maximum reasonable counter value to prevent rendering/memory issues
static constexpr int DEFAULT_MAX_VALUE = 9999;
public:
Server_Counter(int _id, const QString &_name, const color &_counterColor, int _radius, int _count = 0);
Server_Counter(int _id,
const QString &_name,
const color &_counterColor,
int _radius,
int _count = 0,
int _minValue = std::numeric_limits<int>::min(),
int _maxValue = DEFAULT_MAX_VALUE);
~Server_Counter()
{
}
@ -59,9 +71,11 @@ public:
{
return count;
}
void setCount(int _count)
bool setCount(int _count)
{
count = _count;
int oldCount = count;
count = qBound(minValue, _count, maxValue);
return count != oldCount;
}
void getInfo(ServerInfo_Counter *info);

View file

@ -66,6 +66,9 @@ Server_Game::Server_Game(const ServerInfo_User &_creatorInfo,
bool _spectatorsSeeEverything,
int _startingLifeTotal,
bool _shareDecklistsOnLoad,
bool _enableCommandZone,
bool _enableCompanionZone,
bool _enableBackgroundZone,
Server_Room *_room)
: QObject(), room(_room), nextPlayerId(0), hostId(0), creatorInfo(new ServerInfo_User(_creatorInfo)),
gameStarted(false), gameClosed(false), gameId(_gameId), password(_password), maxPlayers(_maxPlayers),
@ -73,9 +76,10 @@ Server_Game::Server_Game(const ServerInfo_User &_creatorInfo,
onlyRegistered(_onlyRegistered), spectatorsAllowed(_spectatorsAllowed),
spectatorsNeedPassword(_spectatorsNeedPassword), spectatorsCanTalk(_spectatorsCanTalk),
spectatorsSeeEverything(_spectatorsSeeEverything), startingLifeTotal(_startingLifeTotal),
shareDecklistsOnLoad(_shareDecklistsOnLoad), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0),
firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
gameMutex()
shareDecklistsOnLoad(_shareDecklistsOnLoad), enableCommandZone(_enableCommandZone),
enableCompanionZone(_enableCompanionZone), enableBackgroundZone(_enableBackgroundZone), inactivityCounter(0),
startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), turnOrderReversed(false),
startTime(QDateTime::currentDateTime()), pingClock(nullptr), gameMutex()
{
currentReplay = new GameReplay;
currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId());

View file

@ -68,6 +68,9 @@ private:
bool spectatorsSeeEverything;
int startingLifeTotal;
bool shareDecklistsOnLoad;
bool enableCommandZone;
bool enableCompanionZone;
bool enableBackgroundZone;
int inactivityCounter;
int startTimeOfThisGame, secondsElapsed;
bool firstGameStarted;
@ -105,6 +108,9 @@ public:
bool _spectatorsSeeEverything,
int _startingLifeTotal,
bool _shareDecklistsOnLoad,
bool _enableCommandZone,
bool _enableCompanionZone,
bool _enableBackgroundZone,
Server_Room *parent);
~Server_Game() override;
Server_Room *getRoom() const
@ -172,6 +178,18 @@ public:
{
return shareDecklistsOnLoad;
}
bool getEnableCommandZone() const
{
return enableCommandZone;
}
bool getEnableCompanionZone() const
{
return enableCompanionZone;
}
bool getEnableBackgroundZone() const
{
return enableBackgroundZone;
}
Response::ResponseCode
checkJoin(ServerInfo_User *user, const QString &_password, bool spectator, bool overrideRestrictions, bool asJudge);
bool containsUser(const QString &userName) const;

View file

@ -13,6 +13,7 @@
#include <QDebug>
#include <QRegularExpression>
#include <algorithm>
#include <libcockatrice/common/counter_ids.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/protocol/pb/command_attach_card.pb.h>
@ -99,6 +100,24 @@ void Server_Player::setupZones()
addCounter(new Server_Counter(6, "x", makeColor(255, 255, 255), 20, 0));
addCounter(new Server_Counter(7, "storm", makeColor(255, 150, 30), 20, 0));
// Command zones for Commander format
if (game->getEnableCommandZone()) {
addZone(new Server_CardZone(this, "command", false, ServerInfo_Zone::PublicZone));
addCounter(new Server_Counter(CounterIds::CommanderTax, CounterNames::CommanderTax, makeColor(128, 128, 128),
20, 0, 0));
addZone(new Server_CardZone(this, "partner", false, ServerInfo_Zone::PublicZone));
addCounter(
new Server_Counter(CounterIds::PartnerTax, CounterNames::PartnerTax, makeColor(128, 128, 128), 20, 0, 0));
}
// Companion and Background zones are independent mechanics
if (game->getEnableCompanionZone()) {
addZone(new Server_CardZone(this, "companion", false, ServerInfo_Zone::PublicZone));
}
if (game->getEnableBackgroundZone()) {
addZone(new Server_CardZone(this, "background", false, ServerInfo_Zone::PublicZone));
}
// ------------------------------------------------------------------
// Assign card ids and create deck from deck list
@ -424,6 +443,10 @@ Server_Player::cmdUndoDraw(const Command_UndoDraw & /*cmd*/, ResponseContainer &
Response::ResponseCode
Server_Player::cmdIncCounter(const Command_IncCounter &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges)
{
// Security note: Counter ownership is implicitly validated through command routing.
// Each participant's processGameCommand operates on their own counters map,
// so players can only modify their own counters.
if (!game->getGameStarted()) {
return Response::RespGameNotStarted;
}
@ -431,17 +454,27 @@ Server_Player::cmdIncCounter(const Command_IncCounter &cmd, ResponseContainer &
return Response::RespContextError;
}
Server_Counter *c = counters.value(cmd.counter_id(), 0);
const int counterId = cmd.counter_id();
// Defensive validation: Commander Tax counters require command zone to be enabled
if ((counterId == CounterIds::CommanderTax || counterId == CounterIds::PartnerTax) &&
!game->getEnableCommandZone()) {
return Response::RespContextError;
}
Server_Counter *c = counters.value(counterId, nullptr);
if (!c) {
return Response::RespNameNotFound;
}
c->setCount(c->getCount() + cmd.delta());
bool didChange = c->setCount(c->getCount() + cmd.delta());
Event_SetCounter event;
event.set_counter_id(c->getId());
event.set_value(c->getCount());
ges.enqueueGameEvent(event, playerId);
if (didChange) {
Event_SetCounter event;
event.set_counter_id(c->getId());
event.set_value(c->getCount());
ges.enqueueGameEvent(event, playerId);
}
return Response::RespOk;
}
@ -475,6 +508,8 @@ Server_Player::cmdCreateCounter(const Command_CreateCounter &cmd, ResponseContai
Response::ResponseCode
Server_Player::cmdSetCounter(const Command_SetCounter &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges)
{
// Security note: Counter ownership is implicitly validated through command routing.
if (!game->getGameStarted()) {
return Response::RespGameNotStarted;
}
@ -482,17 +517,27 @@ Server_Player::cmdSetCounter(const Command_SetCounter &cmd, ResponseContainer &
return Response::RespContextError;
}
Server_Counter *c = counters.value(cmd.counter_id(), 0);
const int counterId = cmd.counter_id();
// Defensive validation: Commander Tax counters require command zone to be enabled
if ((counterId == CounterIds::CommanderTax || counterId == CounterIds::PartnerTax) &&
!game->getEnableCommandZone()) {
return Response::RespContextError;
}
Server_Counter *c = counters.value(counterId, nullptr);
if (!c) {
return Response::RespNameNotFound;
}
c->setCount(cmd.value());
bool didChange = c->setCount(cmd.value());
Event_SetCounter event;
event.set_counter_id(c->getId());
event.set_value(c->getCount());
ges.enqueueGameEvent(event, playerId);
if (didChange) {
Event_SetCounter event;
event.set_counter_id(c->getId());
event.set_value(c->getCount());
ges.enqueueGameEvent(event, playerId);
}
return Response::RespOk;
}

View file

@ -174,6 +174,10 @@ public:
{
return false;
}
virtual bool permitCommandZone() const
{
return true; // Default: allow command zones
}
Server_DatabaseInterface *getDatabaseInterface() const;
int getNextLocalGameId()

View file

@ -830,6 +830,12 @@ 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;
// Only enable command zone if server permits it
bool enableCommandZone = cmd.has_enable_command_zone() && cmd.enable_command_zone() && server->permitCommandZone();
// Companion and Background zones are independent of command zone (explicit opt-in)
bool enableCompanionZone = cmd.has_enable_companion_zone() && cmd.enable_companion_zone();
bool enableBackgroundZone = cmd.has_enable_background_zone() && cmd.enable_background_zone();
const int gameId = databaseInterface->getNextGameId();
if (gameId == -1) {
@ -841,7 +847,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
auto *game = new Server_Game(copyUserInfo(false), gameId, description, QString::fromStdString(cmd.password()),
cmd.max_players(), gameTypes, cmd.only_buddies(), onlyRegisteredUsers,
cmd.spectators_allowed(), cmd.spectators_need_password(), cmd.spectators_can_talk(),
cmd.spectators_see_everything(), startingLifeTotal, shareDecklistsOnLoad, room);
cmd.spectators_see_everything(), startingLifeTotal, shareDecklistsOnLoad,
enableCommandZone, enableCompanionZone, enableBackgroundZone, room);
game->addPlayer(this, rc, asSpectator, asJudge, false);
room->addGame(game);