From a969e4622c04a5bbc9c9833b377e51164bf08ab8 Mon Sep 17 00:00:00 2001 From: Christian Daniel Jensen Date: Tue, 3 Nov 2026 16:38:20 -0700 Subject: [PATCH] Add sixteen server-synchronized Essence resource pools per match player --- README.md | 6 + cockatrice/CMakeLists.txt | 1 + .../player/essence_pool_item.cpp | 186 ++++++++++++++++++ .../game_graphics/player/essence_pool_item.h | 57 ++++++ .../player/player_graphics_item.cpp | 19 +- .../player/player_graphics_item.h | 2 + doc/continuumatrice-match-essence.md | 83 ++++++++ .../libcockatrice/card/essence_types.h | 4 +- .../server/remote/game/server_player.cpp | 42 +++- libcockatrice_utility/CMakeLists.txt | 1 + .../libcockatrice/utility/essence_counter.h | 55 ++++++ tests/essence_types_test.cpp | 23 +++ 12 files changed, 466 insertions(+), 13 deletions(-) create mode 100644 cockatrice/src/game_graphics/player/essence_pool_item.cpp create mode 100644 cockatrice/src/game_graphics/player/essence_pool_item.h create mode 100644 doc/continuumatrice-match-essence.md create mode 100644 libcockatrice_utility/libcockatrice/utility/essence_counter.h diff --git a/README.md b/README.md index 482492c2d..19566f51a 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,9 @@ This is an independent fork, **not** an official Cockatrice release. This repository includes the full dedicated multiplayer server, **Continuum-Servatrice**, built with the `continuum-servatrice` CMake target (default `WITH_SERVER=ON`). Its build output is `build/continuum-servatrice/continuum-servatrice`. Use the standalone `continuum_servatrice` MySQL database and the provided server-specific SQL schema/config; do not reuse MTG Servatrice's database or tables. [Dedicated server hosting and database instructions](doc/continuum-servatrice-hosting.md) + +## In-match Essence pools + +Each player has a server-authoritative **16-type Essence pool** (`M S O G E A Q C J B R L V T P N`), starting at zero. The match displays all balances beside the battlefield with quick gain/spend/set controls. Essence isn't generated by drawing land cards, and there is no automatic per-turn resource refill or cost spending until actual CCG gameplay rules define those mechanics. Continuum-Servatrice and the local server both use the same counter protocol and state. + +[In-match Essence resource pools and test checklist](doc/continuumatrice-match-essence.md) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bb0caf1c6..219dbecf2 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -107,6 +107,7 @@ set(cockatrice_SOURCES src/game_graphics/tally/stats_tally.cpp src/game_graphics/tally/subtype_tally.cpp src/game_graphics/tally/tally.cpp + src/game_graphics/player/essence_pool_item.cpp src/game_graphics/player/player_area.cpp src/game_graphics/player/player_dialogs.cpp src/game_graphics/player/player_graphics_item.cpp diff --git a/cockatrice/src/game_graphics/player/essence_pool_item.cpp b/cockatrice/src/game_graphics/player/essence_pool_item.cpp new file mode 100644 index 000000000..fcd8441c3 --- /dev/null +++ b/cockatrice/src/game_graphics/player/essence_pool_item.cpp @@ -0,0 +1,186 @@ +#include "essence_pool_item.h" + +#include "../../interface/pixel_map_generator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +EssencePoolItem::EssencePoolItem(PlayerLogic *_player, QGraphicsItem *parent) + : QGraphicsObject(parent), player(_player) +{ + setAcceptedMouseButtons(Qt::LeftButton | Qt::RightButton); + setAcceptHoverEvents(true); + setZValue(5); + + connect(player, &PlayerLogic::counterAdded, this, &EssencePoolItem::attachCounter); + connect(player, &PlayerLogic::counterRemoved, this, &EssencePoolItem::detachCounter); + + // Reconnect and late view construction must paint the full snapshot, + // even when the counterAdded events happened before this item existed. + for (CounterState *state : player->getCounters()) { + attachCounter(state); + } + setToolTip(tr("Essence pool — left click +1, right click -1, Shift+left to set")); +} + +QRectF EssencePoolItem::boundingRect() const +{ + return QRectF(0, 0, Width, Height); +} + +void EssencePoolItem::attachCounter(CounterState *state) +{ + if (!state) { + return; + } + const QChar code = EssenceCounter::codeFromName(state->getName()); + if (code.isNull()) { + return; + } + counters.insert(code, state); + connect(state, &CounterState::valueChanged, this, [this](int, int, bool) { update(); }); + update(); +} + +void EssencePoolItem::detachCounter(int id) +{ + for (auto it = counters.begin(); it != counters.end();) { + if (it.value() && it.value()->getId() == id) { + it = counters.erase(it); + } else { + ++it; + } + } + update(); +} + +CounterState *EssencePoolItem::stateFor(QChar code) const +{ + return counters.value(code).data(); +} + +QChar EssencePoolItem::codeAt(const QPointF &position) const +{ + const qreal x = position.x() - Padding; + const qreal y = position.y() - HeaderHeight; + if (x < 0 || y < 0 || x >= Columns * CellWidth || y >= 4 * CellHeight) { + return QChar(); + } + const int col = static_cast(x) / CellWidth; + const int row = static_cast(y) / CellHeight; + const int index = row * Columns + col; + const QString &codes = EssenceCounter::symbols(); + return index >= 0 && index < codes.size() ? codes.at(index) : QChar(); +} + +void EssencePoolItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) +{ + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(QPen(QColor(118, 125, 158), 1)); + painter->setBrush(QColor(16, 19, 31, 232)); + painter->drawRoundedRect(boundingRect().adjusted(0.5, 0.5, -0.5, -0.5), 7, 7); + + QFont titleFont = painter->font(); + titleFont.setBold(true); + titleFont.setPixelSize(15); + painter->setFont(titleFont); + painter->setPen(QColor(236, 238, 248)); + painter->drawText(QRectF(8, 3, Width - 16, 24), Qt::AlignCenter, tr("ESSENCE POOL")); + + QFont countFont = painter->font(); + countFont.setBold(true); + countFont.setPixelSize(14); + painter->setFont(countFont); + + const QString &codes = EssenceCounter::symbols(); + for (int i = 0; i < codes.size(); ++i) { + const QChar code = codes.at(i); + const int col = i % Columns; + const int row = i / Columns; + const qreal x = Padding + col * CellWidth; + const qreal y = HeaderHeight + row * CellHeight; + const QRectF tile(x, y, CellWidth - 3, CellHeight - 3); + + painter->setPen(QPen(QColor(85, 91, 119), 1)); + painter->setBrush(QColor(43, 47, 67, 228)); + painter->drawRoundedRect(tile, 5, 5); + + const QPixmap icon = ManaSymbolPixmapGenerator::generatePixmap(QString(code), QSize(32, 32)); + if (!icon.isNull()) { + painter->drawPixmap(QPointF(x + 9, y + 2), icon); + } else { + painter->setPen(Qt::white); + painter->drawText(QRectF(x, y + 2, CellWidth - 3, 30), Qt::AlignCenter, QString(code)); + } + + CounterState *state = stateFor(code); + const int count = state ? state->getValue() : 0; + painter->setPen(count > 0 ? QColor(255, 244, 191) : QColor(178, 182, 200)); + painter->drawText(QRectF(x, y + 33, CellWidth - 3, 18), + Qt::AlignCenter, QString::number(count)); + } + + painter->restore(); +} + +void EssencePoolItem::mousePressEvent(QGraphicsSceneMouseEvent *event) +{ + const QChar code = codeAt(event->pos()); + CounterState *state = stateFor(code); + if (!state || !player->getPlayerInfo()->getLocalOrJudge()) { + event->ignore(); + return; + } + PlayerActions *actions = player->getPlayerActions(); + if (event->button() == Qt::LeftButton && (event->modifiers() & Qt::ShiftModifier)) { + const QString label = code == QChar('N') ? tr("Colorless (N)") : + code == QChar('J') ? tr("Jasper (J)") : tr("Essence %1").arg(code); + bool accepted = false; + QWidget *parent = event->widget() ? event->widget()->window() : nullptr; + const int value = QInputDialog::getInt(parent, tr("Set Essence"), + tr("Available %1:").arg(label), + state->getValue(), 0, std::numeric_limits::max(), 1, &accepted); + if (accepted) { + actions->actSetCounter(state->getId(), value); + } + } else if (event->button() == Qt::LeftButton) { + actions->actIncrementCounter(state->getId(), 1); + } else if (event->button() == Qt::RightButton) { + if (state->getValue() > 0) { + actions->actIncrementCounter(state->getId(), -1); + } + } else { + event->ignore(); + return; + } + event->accept(); +} + +void EssencePoolItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) +{ + const QChar code = codeAt(event->pos()); + CounterState *state = stateFor(code); + if (!state) { + setToolTip(tr("Essence pool — all types start at zero")); + return; + } + const QString name = code == QChar('N') ? tr("Colorless Essence") : + code == QChar('J') ? tr("Jasper Essence") : tr("Essence %1").arg(code); + setToolTip(tr("%1: %2 available\nLeft +1 | Right -1 | Shift+Left: Set") + .arg(name).arg(state->getValue())); +} diff --git a/cockatrice/src/game_graphics/player/essence_pool_item.h b/cockatrice/src/game_graphics/player/essence_pool_item.h new file mode 100644 index 000000000..1b02b740d --- /dev/null +++ b/cockatrice/src/game_graphics/player/essence_pool_item.h @@ -0,0 +1,57 @@ +#ifndef CONTINUUMATRICE_ESSENCE_POOL_ITEM_H +#define CONTINUUMATRICE_ESSENCE_POOL_ITEM_H + +#include +#include +#include + +class CounterState; +class PlayerLogic; +class QGraphicsSceneMouseEvent; +class QGraphicsSceneHoverEvent; +class QPainter; +class QStyleOptionGraphicsItem; +class QWidget; + +/** + * An interactive, sixteen-type Essence resource pool attached to a player + * battlefield. All values are read from the existing server-owned CounterState + * objects; this graphics item does NOT own mutable match resource balances. + * + * Left click +1, right click -1, Shift+left to set an exact nonnegative amount. + * Only the owning player or a game judge can modify the pool. + */ +class EssencePoolItem final : public QGraphicsObject +{ + Q_OBJECT + +public: + static constexpr int Width = 224; + static constexpr int Height = 258; + + explicit EssencePoolItem(PlayerLogic *player, QGraphicsItem *parent = nullptr); + + QRectF boundingRect() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + +protected: + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; + void hoverMoveEvent(QGraphicsSceneHoverEvent *event) override; + +private: + static constexpr int Columns = 4; + static constexpr int Padding = 8; + static constexpr int CellWidth = 52; + static constexpr int CellHeight = 54; + static constexpr int HeaderHeight = 29; + + [[nodiscard]] QChar codeAt(const QPointF &position) const; + [[nodiscard]] CounterState *stateFor(QChar code) const; + void attachCounter(CounterState *state); + void detachCounter(int id); + + PlayerLogic *player; + QHash> counters; +}; + +#endif // CONTINUUMATRICE_ESSENCE_POOL_ITEM_H diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index fbc70680d..165c5b6fc 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -20,6 +20,7 @@ #include "libcockatrice/utility/card_ref.h" #include "menu/player_menu.h" #include "player_area.h" +#include "essence_pool_item.h" #include "player_dialogs.h" #include "player_target.h" @@ -40,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -51,7 +53,10 @@ namespace { -constexpr qreal EXTRA_PILE_COLUMN_WIDTH = CardDimensions::HEIGHT_F + 24; +// Reserve a third, compact column for the synchronized 4x4 Essence pool. +// This keeps the 16 resource balances visible without obscuring the table. +constexpr qreal EXTRA_PILE_COLUMN_WIDTH = + CardDimensions::HEIGHT_F + 24 + EssencePoolItem::Width + 16; } // namespace PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) @@ -103,6 +108,13 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) initializeZones(); + // Board-owned renderer only. All Essence balances live in player runtime + // CounterState and are sent/validated by the server. + essencePool = new EssencePoolItem(player, this); + const qreal essenceLeft = CardDimensions::HEIGHT_F + counterAreaWidth + 15 + 8 + + CardDimensions::HEIGHT_F + 14; + essencePool->setPos(essenceLeft, 14); + connect(player, &PlayerLogic::addViewCustomZoneActionToCustomZoneMenu, this, &PlayerGraphicsItem::onCustomZoneAdded); @@ -310,6 +322,11 @@ void PlayerGraphicsItem::setMirrored(bool _mirrored) void PlayerGraphicsItem::onCounterAdded(CounterState *state) { + // The Essence pool owns its own compact 4x4 display. Never add sixteen + // full-size generic counters down the narrow life-counter column. + if (EssenceCounter::isEssenceCounter(state->getName())) { + return; + } AbstractCounter *widget; if (state->getName() == "life") { widget = playerTarget->addCounter(state); diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.h b/cockatrice/src/game_graphics/player/player_graphics_item.h index 237fccb55..dae758745 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.h +++ b/cockatrice/src/game_graphics/player/player_graphics_item.h @@ -31,6 +31,7 @@ class TableZone; class AbstractCounter; class CardZone; class CounterState; +class EssencePoolItem; class PlayerArea; class PlayerLogic; struct CardRef; @@ -160,6 +161,7 @@ private: PlayerDialogs *playerDialogs; PlayerArea *playerArea; PlayerTarget *playerTarget; + EssencePoolItem *essencePool = nullptr; QMap counterWidgets; QMap zoneGraphicsItems; PileZone *deckZoneGraphicsItem; diff --git a/doc/continuumatrice-match-essence.md b/doc/continuumatrice-match-essence.md new file mode 100644 index 000000000..395ef2afa --- /dev/null +++ b/doc/continuumatrice-match-essence.md @@ -0,0 +1,83 @@ +# Match Essence pools (Continuum Universes CCG) + +Continuumatrice matches provide **16 independent Essence balances per player**, using the canonical sequence: + +```text +M S O G E A Q C J B R L V T P N +``` + +**J** is Jasper; **N** is colorless. These are not Magic mana colors, and Essence is not primarily obtained by drawing land cards. + +## Behavior + +The client displays a compact **4 × 4 Essence Pool** in a reserved column beside each player's battlefield. The panel shows the user's installed `icons/essence/.svg` artwork and the current quantity, including zero. + +- **Left-click:** add one Essence of that type (gain). +- **Right-click:** remove one Essence of that type (spend), when the balance is positive. +- **Shift + left-click:** set an exact nonnegative balance through a number dialog. +- Other players and spectators can **view** the balances; only the player controlling the pool (or a judge) can modify it. + +For example, generating three Jasper Essence from a card ability currently means clicking `J` three times or setting its balance. Likewise, paying two Jasper means decrementing its balance twice. Future automated card effects should call a validated resource transaction action rather than simulating mouse clicks. + +### What this change does **not** assume + +It does not invent Essence generation rules, a land-based resource curve, turn-based Essence resets, automatic cost deduction, overdraft/Essence debt, or activation triggers. The core match tracks available resources, but **gameplay effects are still manual**. Define those rules separately before implementing them. + +## Server-authoritative state + +The existing Cockatrice-derived counter protocol already supports all the required functionality. The fork's shared `EssenceCounter` helper (`libcockatrice_utility/libcockatrice/utility/essence_counter.h`) defines stable counter names and the canonical symbol order. + +The server creates sixteen counters at game setup with IDs `1..16` and names `essence:M`, `essence:S`, ..., `essence:N`. The existing life counter retains ID `0`. These names are protocol/storage identifiers; client gameplay displays the corresponding Essence symbols rather than exposing raw IDs. + +Resource counters: +- Start at `0`, independently for every player. +- Cannot go below `0` (validated by the server for increment **and** set commands). +- Cannot be deleted or created a second time via the generic counter commands. +- Are serialized to client snapshots, replay events, and reconnect state by the existing counter protocol. +- Do not live on card ScriptableObjects, card metadata/XML, or a global static client singleton. + +`Server_Player` implements this for both the self-hosted **Continuum-Servatrice** and the embedded/local game server because they share the same player server code. **Both client and server must use a matching Continuumatrice build**. The regular MTG Cockatrice client/server do not provide the native Essence Pool display. + +## Implementation map + +| File | Responsibility | +| --- | --- | +| `libcockatrice_utility/libcockatrice/utility/essence_counter.h` | Canonical Essence order, protocol names, nonnegative values | +| `libcockatrice_card/libcockatrice/card/essence_types.h` | Card-cost parser uses the same source of symbols | +| `libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp` | Server counter initialization, updates, permanent-counter restrictions | +| `cockatrice/src/game_graphics/player/essence_pool_item.h/.cpp` | Compact graphical resource display, manual controls, server-state observation | +| `cockatrice/src/game_graphics/player/player_graphics_item.cpp` | Places Essence pool beside player's board and excludes it from the narrow ordinary-counter stack | +| `tests/essence_types_test.cpp` | Protocol-name round trips and nonnegative value utility checks | + +The panel never owns the true resource quantities. It listens for `CounterState::valueChanged` and sends normal `actIncrementCounter`/`actSetCounter` commands through `PlayerActions`. Only the server authoritatively changes the stored balance, so remote clients and replays see the same values. + +## Build and tests + +On the `feature/continuumatrice-essence-match-pools` branch: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_CLIENT=ON -DWITH_SERVER=ON -DWITH_ORACLE=OFF -DTEST=ON + +cmake --build build --target continuumatrice continuum-servatrice essence_types_test --parallel 16 + +ctest --test-dir build -R EssenceTypesTest --output-on-failure + +./build/continuumatrice/continuumatrice +``` + +## Functional acceptance checklist + +- [ ] In a new local game, each player has a 4×4 pool with exactly 16 Essence types and zero balances. +- [ ] Gaining `J` leaves `C`, `B`, and `N` unchanged; right-clicking `J` at zero cannot make it negative. +- [ ] Shift+left can set `N` (colorless) to a specific nonnegative amount. +- [ ] The normal life counter still works; no default MTG W/U/B/R/G/X/Storm counters appear. +- [ ] Both players see the same balance after a command is processed by the server. +- [ ] A spectator can view but not modify another player's Essence. +- [ ] Disconnect/reconnect restores all 16 values from the game-state snapshot. +- [ ] Replay playback restores previously recorded Essence changes. +- [ ] A remote game running Continuum-Servatrice matches the local game behavior. +- [ ] The Essence panel does not overlap the three battlefield bands or Limbo/Refuge/Oblivion piles at normal zoom. + +**Current status:** Source implementation committed. This server/client change has **not** been compiled or tested in a live match yet. The new browser and match UI need user-side smoke testing. diff --git a/libcockatrice_card/libcockatrice/card/essence_types.h b/libcockatrice_card/libcockatrice/card/essence_types.h index 4f8ee96cf..cc0246157 100644 --- a/libcockatrice_card/libcockatrice/card/essence_types.h +++ b/libcockatrice_card/libcockatrice/card/essence_types.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace Essence { @@ -13,8 +14,7 @@ namespace Essence // The other codes have no names, colors or game mechanics assigned here. inline const QString &symbols() { - static const QString value = QStringLiteral("MSOGEAQCJBRLVTPN"); - return value; + return EssenceCounter::symbols(); } inline bool isSymbol(const QChar symbol) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index 63614b9fd..0d6a0380e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -57,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -110,13 +111,16 @@ void Server_Player::setupZones() addZone(new Server_CardZone(this, ZoneNames::OBLIVION, false, ServerInfo_Zone::PublicZone)); addCounter(new Server_Counter(0, "life", makeColor(255, 255, 255), 25, game->getStartingLifeTotal())); - addCounter(new Server_Counter(1, "w", makeColor(255, 255, 150), 20, 0)); - addCounter(new Server_Counter(2, "u", makeColor(150, 150, 255), 20, 0)); - addCounter(new Server_Counter(3, "b", makeColor(150, 150, 150), 20, 0)); - addCounter(new Server_Counter(4, "r", makeColor(250, 150, 150), 20, 0)); - addCounter(new Server_Counter(5, "g", makeColor(150, 255, 150), 20, 0)); - addCounter(new Server_Counter(6, "x", makeColor(255, 255, 255), 20, 0)); - addCounter(new Server_Counter(7, "storm", makeColor(255, 150, 30), 20, 0)); + // Continuum Universes CCG: every player has an independent sixteen-type + // Essence pool. It starts at zero and can change without drawing lands. + // These counters are the authoritative game-state source: the existing + // counter protocol automatically handles spectators, replays and reconnect. + // Stable ids 1..16 correspond to M,S,O,G,E,A,Q,C,J,B,R,L,V,T,P,N. + int essenceId = 1; + for (const QChar code : EssenceCounter::symbols()) { + addCounter(new Server_Counter(essenceId++, EssenceCounter::name(code), + makeColor(168, 172, 189), 18, 0)); + } // ------------------------------------------------------------------ @@ -480,7 +484,14 @@ Server_Player::cmdIncCounter(const Command_IncCounter &cmd, ResponseContainer & return Response::RespNameNotFound; } - bool didChange = c->incrementCount(cmd.delta()); + const int oldValue = c->getCount(); + // Essence is an available-resource balance, never a debt counter. + // Leave ordinary player counters unchanged. + (void)c->incrementCount(cmd.delta()); + if (EssenceCounter::isEssenceCounter(c->getName()) && c->getCount() < 0) { + (void)c->setCount(EssenceCounter::clampBalance(c->getCount())); + } + const bool didChange = c->getCount() != oldValue; if (didChange) { Event_SetCounter event; event.set_counter_id(c->getId()); @@ -501,7 +512,13 @@ Server_Player::cmdCreateCounter(const Command_CreateCounter &cmd, ResponseContai return Response::RespContextError; } - auto *c = new Server_Counter(newCounterId(), nameFromStdString(cmd.counter_name()), cmd.counter_color(), + const QString requestedName = nameFromStdString(cmd.counter_name()); + // The canonical pool is unique, present for every player, and may not + // be forged or replaced using a generic "create counter" command. + if (EssenceCounter::isEssenceCounter(requestedName)) { + return Response::RespContextError; + } + auto *c = new Server_Counter(newCounterId(), requestedName, cmd.counter_color(), cmd.radius(), cmd.value()); addCounter(c); @@ -533,7 +550,8 @@ Server_Player::cmdSetCounter(const Command_SetCounter &cmd, ResponseContainer & return Response::RespNameNotFound; } - bool didChange = c->setCount(cmd.value()); + const int newValue = EssenceCounter::isEssenceCounter(c->getName()) ? EssenceCounter::clampBalance(cmd.value()) : cmd.value(); + const bool didChange = c->setCount(newValue); if (didChange) { Event_SetCounter event; event.set_counter_id(c->getId()); @@ -559,6 +577,10 @@ Server_Player::cmdDelCounter(const Command_DelCounter &cmd, ResponseContainer & if (!counter) { return Response::RespNameNotFound; } + // Canonical Essence counters are permanent for the duration of a match. + if (EssenceCounter::isEssenceCounter(counter->getName())) { + return Response::RespContextError; + } counters.remove(counterId); delete counter; diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 8d2cb5b92..362e66095 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -19,6 +19,7 @@ set(UTILITY_HEADERS libcockatrice/utility/cryptoutil.h libcockatrice/utility/debouncer.h libcockatrice/utility/expression.h + libcockatrice/utility/essence_counter.h libcockatrice/utility/installer_close_protocol.h libcockatrice/utility/levenshtein.h libcockatrice/utility/local_server_name.h diff --git a/libcockatrice_utility/libcockatrice/utility/essence_counter.h b/libcockatrice_utility/libcockatrice/utility/essence_counter.h new file mode 100644 index 000000000..5a82af32b --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/essence_counter.h @@ -0,0 +1,55 @@ +#ifndef CONTINUUMATRICE_ESSENCE_COUNTER_H +#define CONTINUUMATRICE_ESSENCE_COUNTER_H + +#include +#include + + +namespace EssenceCounter +{ +// A single authoritative resource-code order shared by card metadata, the +// client match display, and the dedicated/local multiplayer servers. +// This is a protocol identity list, not a definition of gameplay generation. +inline const QString &symbols() +{ + static const QString value = QStringLiteral("MSOGEAQCJBRLVTPN"); + return value; +} + +inline bool isSymbol(QChar code) +{ + return symbols().contains(code); +} + +// Names are serialized by Cockatrice's existing ServerInfo_Counter protocol. +// Keep these stable for replays, game snapshots, and reconnects. +inline QString name(QChar code) +{ + return isSymbol(code) ? QStringLiteral("essence:") + code : QString(); +} + +inline QChar codeFromName(const QString &counterName) +{ + constexpr qsizetype prefixSize = 8; // "essence:" + if (counterName.size() != prefixSize + 1 || + !counterName.startsWith(QStringLiteral("essence:"))) { + return QChar(); + } + const QChar code = counterName.at(prefixSize); + return isSymbol(code) ? code : QChar(); +} + +// Resource balances cannot be negative. The caller handles overflow-safe +// addition separately; this function only clamps the final amount. +inline int clampBalance(int amount) +{ + return amount < 0 ? 0 : amount; +} + +inline bool isEssenceCounter(const QString &counterName) +{ + return !codeFromName(counterName).isNull(); +} +} // namespace EssenceCounter + +#endif // CONTINUUMATRICE_ESSENCE_COUNTER_H diff --git a/tests/essence_types_test.cpp b/tests/essence_types_test.cpp index 69f47c360..7a9cdf3d7 100644 --- a/tests/essence_types_test.cpp +++ b/tests/essence_types_test.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -15,6 +16,28 @@ TEST(EssenceTypesTest, HasExactlySixteenCanonicalSymbols) EXPECT_FALSE(Essence::isSymbol(QChar('U'))); // MTG blue is not Essence. } +TEST(EssenceTypesTest, NetworkCounterNamesHaveStableRoundTrip) +{ + EXPECT_EQ(Essence::symbols(), EssenceCounter::symbols()); + int position = 1; // Counter id 0 is the existing life counter. + for (const QChar code : EssenceCounter::symbols()) { + const QString networkName = EssenceCounter::name(code); + EXPECT_TRUE(networkName.startsWith(QStringLiteral("essence:"))); + EXPECT_EQ(EssenceCounter::codeFromName(networkName), code); + EXPECT_TRUE(EssenceCounter::isEssenceCounter(networkName)); + ++position; + } + EXPECT_EQ(position, 17); + EXPECT_TRUE(EssenceCounter::name(QChar('W')).isEmpty()); + EXPECT_FALSE(EssenceCounter::isEssenceCounter(QStringLiteral("essence:W"))); + EXPECT_FALSE(EssenceCounter::isEssenceCounter(QStringLiteral("essence:MM"))); + EXPECT_FALSE(EssenceCounter::isEssenceCounter(QStringLiteral("life"))); + EXPECT_EQ(EssenceCounter::clampBalance(-1), 0); + EXPECT_EQ(EssenceCounter::clampBalance(-900), 0); + EXPECT_EQ(EssenceCounter::clampBalance(0), 0); + EXPECT_EQ(EssenceCounter::clampBalance(19), 19); +} + TEST(EssenceTypesTest, ParsesAllBracedAndUnbracedTypes) { const QString raw = QStringLiteral("{M}{S}{O}{G}{E}{A}{Q}{C}{J}{B}{R}{L}{V}{T}{P}{N}");