Add sixteen server-synchronized Essence resource pools per match player

This commit is contained in:
Christian Daniel Jensen 2026-11-03 16:38:20 -07:00
parent 00a7feff25
commit a969e4622c
12 changed files with 466 additions and 13 deletions

View file

@ -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. 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) [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)

View file

@ -107,6 +107,7 @@ set(cockatrice_SOURCES
src/game_graphics/tally/stats_tally.cpp src/game_graphics/tally/stats_tally.cpp
src/game_graphics/tally/subtype_tally.cpp src/game_graphics/tally/subtype_tally.cpp
src/game_graphics/tally/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_area.cpp
src/game_graphics/player/player_dialogs.cpp src/game_graphics/player/player_dialogs.cpp
src/game_graphics/player/player_graphics_item.cpp src/game_graphics/player/player_graphics_item.cpp

View file

@ -0,0 +1,186 @@
#include "essence_pool_item.h"
#include "../../interface/pixel_map_generator.h"
#include <QFont>
#include <QGraphicsSceneHoverEvent>
#include <QGraphicsSceneMouseEvent>
#include <QInputDialog>
#include <QPainter>
#include <QPen>
#include <QRectF>
#include <QSize>
#include <QWidget>
#include <libcockatrice/game/board/counter_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/utility/essence_counter.h>
#include <limits>
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<int>(x) / CellWidth;
const int row = static_cast<int>(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<int>::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()));
}

View file

@ -0,0 +1,57 @@
#ifndef CONTINUUMATRICE_ESSENCE_POOL_ITEM_H
#define CONTINUUMATRICE_ESSENCE_POOL_ITEM_H
#include <QGraphicsObject>
#include <QHash>
#include <QPointer>
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<QChar, QPointer<CounterState>> counters;
};
#endif // CONTINUUMATRICE_ESSENCE_POOL_ITEM_H

View file

@ -20,6 +20,7 @@
#include "libcockatrice/utility/card_ref.h" #include "libcockatrice/utility/card_ref.h"
#include "menu/player_menu.h" #include "menu/player_menu.h"
#include "player_area.h" #include "player_area.h"
#include "essence_pool_item.h"
#include "player_dialogs.h" #include "player_dialogs.h"
#include "player_target.h" #include "player_target.h"
@ -40,6 +41,7 @@
#include <libcockatrice/game/player/player_actions.h> #include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_info.h> #include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h> #include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/utility/essence_counter.h>
#include <libcockatrice/game/zones/card_zone_logic.h> #include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/hand_zone_logic.h> #include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/game/zones/pile_zone_logic.h> #include <libcockatrice/game/zones/pile_zone_logic.h>
@ -51,7 +53,10 @@
namespace 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 } // namespace
PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
@ -103,6 +108,13 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
initializeZones(); 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, connect(player, &PlayerLogic::addViewCustomZoneActionToCustomZoneMenu, this,
&PlayerGraphicsItem::onCustomZoneAdded); &PlayerGraphicsItem::onCustomZoneAdded);
@ -310,6 +322,11 @@ void PlayerGraphicsItem::setMirrored(bool _mirrored)
void PlayerGraphicsItem::onCounterAdded(CounterState *state) 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; AbstractCounter *widget;
if (state->getName() == "life") { if (state->getName() == "life") {
widget = playerTarget->addCounter(state); widget = playerTarget->addCounter(state);

View file

@ -31,6 +31,7 @@ class TableZone;
class AbstractCounter; class AbstractCounter;
class CardZone; class CardZone;
class CounterState; class CounterState;
class EssencePoolItem;
class PlayerArea; class PlayerArea;
class PlayerLogic; class PlayerLogic;
struct CardRef; struct CardRef;
@ -160,6 +161,7 @@ private:
PlayerDialogs *playerDialogs; PlayerDialogs *playerDialogs;
PlayerArea *playerArea; PlayerArea *playerArea;
PlayerTarget *playerTarget; PlayerTarget *playerTarget;
EssencePoolItem *essencePool = nullptr;
QMap<int, AbstractCounter *> counterWidgets; QMap<int, AbstractCounter *> counterWidgets;
QMap<QString, CardZone *> zoneGraphicsItems; QMap<QString, CardZone *> zoneGraphicsItems;
PileZone *deckZoneGraphicsItem; PileZone *deckZoneGraphicsItem;

View file

@ -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/<code>.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.

View file

@ -6,6 +6,7 @@
#include <QRegularExpressionMatchIterator> #include <QRegularExpressionMatchIterator>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <libcockatrice/utility/essence_counter.h>
namespace Essence namespace Essence
{ {
@ -13,8 +14,7 @@ namespace Essence
// The other codes have no names, colors or game mechanics assigned here. // The other codes have no names, colors or game mechanics assigned here.
inline const QString &symbols() inline const QString &symbols()
{ {
static const QString value = QStringLiteral("MSOGEAQCJBRLVTPN"); return EssenceCounter::symbols();
return value;
} }
inline bool isSymbol(const QChar symbol) inline bool isSymbol(const QChar symbol)

View file

@ -57,6 +57,7 @@
#include <libcockatrice/protocol/pb/serverinfo_player.pb.h> #include <libcockatrice/protocol/pb/serverinfo_player.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h> #include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/utility/color.h> #include <libcockatrice/utility/color.h>
#include <libcockatrice/utility/essence_counter.h>
#include <libcockatrice/utility/string_limits.h> #include <libcockatrice/utility/string_limits.h>
#include <libcockatrice/utility/zone_names.h> #include <libcockatrice/utility/zone_names.h>
#include <qalgorithms.h> #include <qalgorithms.h>
@ -110,13 +111,16 @@ void Server_Player::setupZones()
addZone(new Server_CardZone(this, ZoneNames::OBLIVION, false, ServerInfo_Zone::PublicZone)); 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(0, "life", makeColor(255, 255, 255), 25, game->getStartingLifeTotal()));
addCounter(new Server_Counter(1, "w", makeColor(255, 255, 150), 20, 0)); // Continuum Universes CCG: every player has an independent sixteen-type
addCounter(new Server_Counter(2, "u", makeColor(150, 150, 255), 20, 0)); // Essence pool. It starts at zero and can change without drawing lands.
addCounter(new Server_Counter(3, "b", makeColor(150, 150, 150), 20, 0)); // These counters are the authoritative game-state source: the existing
addCounter(new Server_Counter(4, "r", makeColor(250, 150, 150), 20, 0)); // counter protocol automatically handles spectators, replays and reconnect.
addCounter(new Server_Counter(5, "g", makeColor(150, 255, 150), 20, 0)); // Stable ids 1..16 correspond to M,S,O,G,E,A,Q,C,J,B,R,L,V,T,P,N.
addCounter(new Server_Counter(6, "x", makeColor(255, 255, 255), 20, 0)); int essenceId = 1;
addCounter(new Server_Counter(7, "storm", makeColor(255, 150, 30), 20, 0)); 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; 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) { if (didChange) {
Event_SetCounter event; Event_SetCounter event;
event.set_counter_id(c->getId()); event.set_counter_id(c->getId());
@ -501,7 +512,13 @@ Server_Player::cmdCreateCounter(const Command_CreateCounter &cmd, ResponseContai
return Response::RespContextError; 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()); cmd.radius(), cmd.value());
addCounter(c); addCounter(c);
@ -533,7 +550,8 @@ Server_Player::cmdSetCounter(const Command_SetCounter &cmd, ResponseContainer &
return Response::RespNameNotFound; 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) { if (didChange) {
Event_SetCounter event; Event_SetCounter event;
event.set_counter_id(c->getId()); event.set_counter_id(c->getId());
@ -559,6 +577,10 @@ Server_Player::cmdDelCounter(const Command_DelCounter &cmd, ResponseContainer &
if (!counter) { if (!counter) {
return Response::RespNameNotFound; 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); counters.remove(counterId);
delete counter; delete counter;

View file

@ -19,6 +19,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/cryptoutil.h libcockatrice/utility/cryptoutil.h
libcockatrice/utility/debouncer.h libcockatrice/utility/debouncer.h
libcockatrice/utility/expression.h libcockatrice/utility/expression.h
libcockatrice/utility/essence_counter.h
libcockatrice/utility/installer_close_protocol.h libcockatrice/utility/installer_close_protocol.h
libcockatrice/utility/levenshtein.h libcockatrice/utility/levenshtein.h
libcockatrice/utility/local_server_name.h libcockatrice/utility/local_server_name.h

View file

@ -0,0 +1,55 @@
#ifndef CONTINUUMATRICE_ESSENCE_COUNTER_H
#define CONTINUUMATRICE_ESSENCE_COUNTER_H
#include <QChar>
#include <QString>
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

View file

@ -1,6 +1,7 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <libcockatrice/card/card_info.h> #include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/essence_types.h> #include <libcockatrice/card/essence_types.h>
#include <libcockatrice/utility/essence_counter.h>
#include <QHash> #include <QHash>
#include <QString> #include <QString>
@ -15,6 +16,28 @@ TEST(EssenceTypesTest, HasExactlySixteenCanonicalSymbols)
EXPECT_FALSE(Essence::isSymbol(QChar('U'))); // MTG blue is not Essence. 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) TEST(EssenceTypesTest, ParsesAllBracedAndUnbracedTypes)
{ {
const QString raw = QStringLiteral("{M}{S}{O}{G}{E}{A}{Q}{C}{J}{B}{R}{L}{V}{T}{P}{N}"); const QString raw = QStringLiteral("{M}{S}{O}{G}{E}{A}{Q}{C}{J}{B}{R}{L}{V}{T}{P}{N}");