[Game] Add Command Zone support with commander tax tracking

- Add CommandZone and CommandZoneLogic for commander
  - Add CommanderTaxCounter
  - Add counter active state protocol (show/hide tax counters)
  - Add "Enable Command Zone" option in game creation dialogs
  - Add context menu actions for command zone operations

Took 9 minutes

Took 11 minutes
This commit is contained in:
DawnFire42 2026-05-21 21:30:40 -04:00
parent 694adc9e64
commit 9b030a3d6b
No known key found for this signature in database
GPG key ID: 24BB855EE2911B33
73 changed files with 1540 additions and 86 deletions

View file

@ -37,6 +37,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_counter_active.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
@ -342,6 +343,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdDelCounter(const Command_D
return Response::RespFunctionNotAllowed;
}
Response::ResponseCode Server_AbstractParticipant::cmdSetCounterActive(const Command_SetCounterActive & /*cmd*/,
ResponseContainer & /*rc*/,
GameEventStorage & /*ges*/)
{
return Response::RespFunctionNotAllowed;
}
Response::ResponseCode Server_AbstractParticipant::cmdNextTurn(const Command_NextTurn & /*cmd*/,
ResponseContainer & /*rc*/,
GameEventStorage & /*ges*/)
@ -525,6 +533,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo
case GameCommand::REVERSE_TURN:
return cmdReverseTurn(command.GetExtension(Command_ReverseTurn::ext), rc, ges);
break;
case GameCommand::SET_COUNTER_ACTIVE:
return cmdSetCounterActive(command.GetExtension(Command_SetCounterActive::ext), rc, ges);
break;
default:
return Response::RespInvalidCommand;
}

View file

@ -41,6 +41,7 @@ class Command_Judge;
class Command_IncCounter;
class Command_CreateCounter;
class Command_SetCounter;
class Command_SetCounterActive;
class Command_DelCounter;
class Command_NextTurn;
class Command_SetActivePhase;
@ -161,6 +162,8 @@ public:
virtual Response::ResponseCode
cmdDelCounter(const Command_DelCounter &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdSetCounterActive(const Command_SetCounterActive &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdNextTurn(const Command_NextTurn &cmd, ResponseContainer &rc, GameEventStorage &ges);
virtual Response::ResponseCode
cmdSetActivePhase(const Command_SetActivePhase &cmd, ResponseContainer &rc, GameEventStorage &ges);

View file

@ -114,8 +114,8 @@ QString Server_Card::setAttribute(CardAttribute attribute, const QString &avalue
bool Server_Card::setCounter(int _id, int value, Event_SetCardCounter *event)
{
// Clamp to valid card counter range [0, MAX_COUNTERS_ON_CARD]
value = qBound(0, value, MAX_COUNTERS_ON_CARD);
// Clamp to valid card counter range [0, MAX_COUNTER_VALUE]
value = qBound(0, value, MAX_COUNTER_VALUE);
const int oldValue = counters.value(_id, 0);
if (value == oldValue) {
@ -140,9 +140,9 @@ bool Server_Card::incrementCounter(int counterId, int delta, Event_SetCardCounte
{
const int oldValue = counters.value(counterId, 0);
const auto result = static_cast<int64_t>(oldValue) + static_cast<int64_t>(delta);
// Clamp to [0, MAX_COUNTERS_ON_CARD] for card counters
// Clamp to [0, MAX_COUNTER_VALUE] for card counters
const int newValue =
static_cast<int>(qBound(static_cast<int64_t>(0), result, static_cast<int64_t>(MAX_COUNTERS_ON_CARD)));
static_cast<int>(qBound(static_cast<int64_t>(0), result, static_cast<int64_t>(MAX_COUNTER_VALUE)));
if (newValue == oldValue) {
return false;

View file

@ -156,7 +156,7 @@ public:
/**
* @brief Sets a card counter to an exact value with clamping.
* @param _id The counter ID.
* @param value The desired value (clamped to [0, MAX_COUNTERS_ON_CARD]; 0 removes the counter).
* @param value The desired value (clamped to [0, MAX_COUNTER_VALUE]; 0 removes the counter).
* @param event Optional event to populate with counter state.
* @return true if the value changed, false otherwise.
*/
@ -168,7 +168,7 @@ public:
* @param event Optional event to populate with counter state.
* @return true if the value changed, false otherwise.
* @note If counter does not exist, starts from 0. Counter is removed if result is 0.
* @note Clamps result to [0, MAX_COUNTERS_ON_CARD].
* @note Clamps result to [0, MAX_COUNTER_VALUE].
*/
[[nodiscard]] bool incrementCounter(int counterId, int delta, Event_SetCardCounter *event = nullptr);
void setTapped(bool _tapped)

View file

@ -1,24 +1,19 @@
#include "server_counter.h"
#include <libcockatrice/protocol/pb/serverinfo_counter.pb.h>
#include <limits>
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(_count), minValue(_minValue),
maxValue(_maxValue)
{
}
//! \todo Extract overflow-safe arithmetic into shared helper.
//! Duplicated in Server_Card::incrementCounter() - keep in sync if modified.
bool Server_Counter::incrementCount(int delta)
{
const int oldCount = count;
const auto result = static_cast<int64_t>(count) + static_cast<int64_t>(delta);
count = static_cast<int>(qBound(static_cast<int64_t>(std::numeric_limits<int>::min()), result,
static_cast<int64_t>(std::numeric_limits<int>::max())));
return count != oldCount;
}
void Server_Counter::getInfo(ServerInfo_Counter *info)
{
info->set_id(id);
@ -26,4 +21,5 @@ void Server_Counter::getInfo(ServerInfo_Counter *info)
info->mutable_counter_color()->CopyFrom(counterColor);
info->set_radius(radius);
info->set_count(count);
info->set_active(active);
}

View file

@ -22,18 +22,18 @@
#include <QString>
#include <libcockatrice/protocol/pb/color.pb.h>
#include <limits>
class ServerInfo_Counter;
/**
* @class Server_Counter
* @brief Represents a player counter with overflow-safe increment arithmetic.
* @brief Represents a player counter with overflow-safe increment arithmetic and optional bounds.
*
* All value modifications return whether the value actually changed,
* enabling callers to skip unnecessary network events.
*
* @note Direct assignment via setCount() does not clamp; only
* incrementCount() enforces int boundary saturation.
* @note Values are clamped to [minValue, maxValue] on both setCount() and incrementCount().
* @note Unlike card counters, player counters are never auto-removed
* when they reach zero - they persist with value 0.
*/
@ -45,9 +45,30 @@ protected:
color counterColor;
int radius;
int count;
int minValue; ///< Minimum allowed value (default: INT_MIN, i.e. unbounded)
int maxValue; ///< Maximum allowed value (default: INT_MAX, i.e. unbounded)
bool active = true; ///< Whether this counter is visible/active (default: true)
static constexpr int DEFAULT_MAX_VALUE = std::numeric_limits<int>::max();
public:
Server_Counter(int _id, const QString &_name, const color &_counterColor, int _radius, int _count = 0);
/**
* @brief Constructs a counter.
* @param _id Unique counter identifier
* @param _name Display name
* @param _counterColor Counter color
* @param _radius Display radius
* @param _count Initial value (default 0)
* @param _minValue Minimum allowed value (default INT_MIN)
* @param _maxValue Maximum allowed value (default INT_MAX)
*/
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()
{
}
@ -71,18 +92,31 @@ public:
{
return count;
}
bool isActive() const
{
return active;
}
/**
* @brief Sets the counter to an exact value.
* @param _count The new value (assigned directly without clamping).
* @return true if the value changed, false otherwise.
* @warning This performs raw assignment. For overflow-safe incrementing,
* use incrementCount().
* @brief Sets the active (visible) state of this counter.
* @param _active True to show the counter, false to hide it
* @return true if the state changed
*/
[[nodiscard]] bool setActive(bool _active)
{
bool oldActive = active;
active = _active;
return active != oldActive;
}
/**
* @brief Sets the counter value, clamping to [minValue, maxValue].
* @param _count The desired new value
* @return true if the clamped value differs from the previous value
* @note For increment operations, prefer incrementCount() which handles overflow safely.
*/
[[nodiscard]] bool setCount(int _count)
{
const int oldCount = count;
count = _count;
int oldCount = count;
count = qBound(minValue, _count, maxValue);
return count != oldCount;
}
@ -90,9 +124,17 @@ public:
* @brief Increments the counter by delta with overflow-safe arithmetic.
* @param delta The amount to add (may be negative for decrement).
* @return true if the value changed, false otherwise.
* @note Clamps result to [INT_MIN, INT_MAX] to prevent overflow.
* @note Clamps result to [minValue, maxValue] to prevent overflow.
*/
[[nodiscard]] bool incrementCount(int delta);
[[nodiscard]] bool incrementCount(int delta)
{
const auto result = static_cast<int64_t>(count) + static_cast<int64_t>(delta);
const int clamped =
static_cast<int>(qBound(static_cast<int64_t>(minValue), result, static_cast<int64_t>(maxValue)));
int oldCount = count;
count = clamped;
return count != oldCount;
}
/**
* @brief Populates info with this counter's current state for network serialization.

View file

@ -67,6 +67,7 @@ Server_Game::Server_Game(const ServerInfo_User &_creatorInfo,
bool _spectatorsSeeEverything,
int _startingLifeTotal,
bool _shareDecklistsOnLoad,
bool _enableCommandZone,
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),
@ -74,9 +75,9 @@ 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), 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

@ -69,6 +69,7 @@ private:
bool spectatorsSeeEverything;
int startingLifeTotal;
bool shareDecklistsOnLoad;
bool enableCommandZone;
int inactivityCounter;
int startTimeOfThisGame, secondsElapsed;
bool firstGameStarted;
@ -106,6 +107,7 @@ public:
bool _spectatorsSeeEverything,
int _startingLifeTotal,
bool _shareDecklistsOnLoad,
bool _enableCommandZone,
Server_Room *parent);
~Server_Game() override;
Server_Room *getRoom() const
@ -173,6 +175,10 @@ public:
{
return shareDecklistsOnLoad;
}
bool getEnableCommandZone() const
{
return enableCommandZone;
}
Response::ResponseCode
checkJoin(ServerInfo_User *user, const QString &_password, bool spectator, bool overrideRestrictions, bool asJudge);
bool containsUser(const QString &userName) const;

View file

@ -27,6 +27,7 @@
#include <libcockatrice/protocol/pb/command_mulligan.pb.h>
#include <libcockatrice/protocol/pb/command_set_active_phase.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter.pb.h>
#include <libcockatrice/protocol/pb/command_set_counter_active.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
@ -39,6 +40,7 @@
#include <libcockatrice/protocol/pb/event_game_log_notice.pb.h>
#include <libcockatrice/protocol/pb/event_player_properties_changed.pb.h>
#include <libcockatrice/protocol/pb/event_set_counter.pb.h>
#include <libcockatrice/protocol/pb/event_set_counter_active.pb.h>
#include <libcockatrice/protocol/pb/event_shuffle.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
@ -47,6 +49,7 @@
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <libcockatrice/utility/color.h>
#include <libcockatrice/utility/counter_ids.h>
#include <libcockatrice/utility/trice_limits.h>
#include <libcockatrice/utility/zone_names.h>
@ -101,6 +104,17 @@ 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 zone for Commander format
if (game->getEnableCommandZone()) {
addZone(new Server_CardZone(this, ZoneNames::COMMAND, false, ServerInfo_Zone::PublicZone));
addCounter(new Server_Counter(CounterIds::CommanderTax, CounterNames::CommanderTax, makeColor(128, 128, 128),
20, 0, 0, MAX_COUNTER_VALUE));
auto *partnerTax = new Server_Counter(CounterIds::PartnerTax, CounterNames::PartnerTax,
makeColor(128, 128, 128), 20, 0, 0, MAX_COUNTER_VALUE);
(void)partnerTax->setActive(false);
addCounter(partnerTax);
}
// ------------------------------------------------------------------
// Assign card ids and create deck from deck list
@ -426,6 +440,12 @@ Server_Player::cmdUndoDraw(const Command_UndoDraw & /*cmd*/, ResponseContainer &
return retVal;
}
bool Server_Player::isCommandZoneCounterBlocked(int counterId) const
{
return (counterId == CounterIds::CommanderTax || counterId == CounterIds::PartnerTax) &&
!game->getEnableCommandZone();
}
Response::ResponseCode
Server_Player::cmdIncCounter(const Command_IncCounter &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges)
{
@ -437,6 +457,11 @@ Server_Player::cmdIncCounter(const Command_IncCounter &cmd, ResponseContainer &
}
const int counterId = cmd.counter_id();
if (isCommandZoneCounterBlocked(counterId)) {
return Response::RespContextError;
}
Server_Counter *c = counters.value(counterId, nullptr);
if (!c) {
return Response::RespNameNotFound;
@ -490,6 +515,11 @@ Server_Player::cmdSetCounter(const Command_SetCounter &cmd, ResponseContainer &
}
const int counterId = cmd.counter_id();
if (isCommandZoneCounterBlocked(counterId)) {
return Response::RespContextError;
}
Server_Counter *c = counters.value(counterId, nullptr);
if (!c) {
return Response::RespNameNotFound;
@ -517,6 +547,11 @@ Server_Player::cmdDelCounter(const Command_DelCounter &cmd, ResponseContainer &
}
const int counterId = cmd.counter_id();
if (isCommandZoneCounterBlocked(counterId)) {
return Response::RespContextError;
}
Server_Counter *counter = counters.value(counterId, nullptr);
if (!counter) {
return Response::RespNameNotFound;
@ -531,6 +566,35 @@ Server_Player::cmdDelCounter(const Command_DelCounter &cmd, ResponseContainer &
return Response::RespOk;
}
Response::ResponseCode Server_Player::cmdSetCounterActive(const Command_SetCounterActive &cmd,
ResponseContainer & /*rc*/,
GameEventStorage &ges)
{
if (!game->getGameStarted()) {
return Response::RespGameNotStarted;
}
if (conceded) {
return Response::RespContextError;
}
const int counterId = cmd.counter_id();
Server_Counter *c = counters.value(counterId, nullptr);
if (!c) {
return Response::RespNameNotFound;
}
bool didChange = c->setActive(cmd.active());
if (didChange) {
Event_SetCounterActive event;
event.set_counter_id(c->getId());
event.set_active(c->isActive());
ges.enqueueGameEvent(event, playerId);
}
return Response::RespOk;
}
Response::ResponseCode
Server_Player::cmdNextTurn(const Command_NextTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/)
{

View file

@ -9,6 +9,7 @@ class Server_Player : public Server_AbstractPlayer
private:
QMap<int, Server_Counter *> counters;
QList<int> lastDrawList;
bool isCommandZoneCounterBlocked(int counterId) const;
public:
Server_Player(Server_Game *_game,
@ -57,6 +58,8 @@ public:
Response::ResponseCode
cmdDelCounter(const Command_DelCounter &cmd, ResponseContainer &rc, GameEventStorage &ges) override;
Response::ResponseCode
cmdSetCounterActive(const Command_SetCounterActive &cmd, ResponseContainer &rc, GameEventStorage &ges) override;
Response::ResponseCode
cmdNextTurn(const Command_NextTurn &cmd, ResponseContainer &rc, GameEventStorage &ges) override;
Response::ResponseCode
cmdSetActivePhase(const Command_SetActivePhase &cmd, ResponseContainer &rc, GameEventStorage &ges) override;

View file

@ -885,10 +885,11 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room
// When server doesn't permit registered users to exist, do not honor only-reg setting
bool onlyRegisteredUsers = cmd.only_registered() && (server->permitUnregisteredUsers());
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);
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, cmd.enable_command_zone(), room);
game->addPlayer(this, rc, asSpectator, asJudge, false);
room->addGame(game);