[DeckList] Add custom deck zones to the deck tree

Introduce user-definable zones nested under a board zone (main, side
or maybeboard) so players can organize cards inside a board without
changing board semantics.

- addCustomZone, renameCustomZone, moveCustomZone and removeCustomZone
  manage zones. Names are unique across the whole deck and the standard
  zone names (main/side/maybeboard/tokens) stay reserved.
- Board zones are created lazily on first use.
- getZoneObjFromName resolves custom names to their nested node so
  addCard and XML loading route cards into them. Unknown names keep
  creating legacy top-level zones.
- deleteNode keeps empty custom zones alive and only prunes empty
  board zones.
- New deck_list_zones test suite locks hash parity with flat decks,
  sideboard size accounting, maybeboard exclusion from plain export
  and native-format round-trips.

Took 17 minutes


Took 11 minutes
This commit is contained in:
Lukas Brübach 2026-08-23 22:23:06 +02:00
parent dba7cc73a4
commit 09260ee7d9
6 changed files with 551 additions and 2 deletions

View file

@ -145,7 +145,8 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode
if (index != -1) {
delete rootNode->takeAt(index);
if (rootNode->empty()) {
// Empty custom zones are kept while empty board zones get pruned.
if (rootNode->empty() && rootNode->getParent() == root) {
deleteNode(rootNode, rootNode->getParent());
}
@ -188,15 +189,151 @@ void DecklistNodeTree::forEachCard(const std::function<void(InnerDecklistNode *,
/**
* Gets the InnerDecklistNode that is the root node for the given zone, creating a new node if it doesn't exist.
*
* Top-level zones take precedence, then deck-unique custom zones nested under boards
* are resolved. Unknown names create a new top-level zone (legacy behavior).
*/
InnerDecklistNode *DecklistNodeTree::getZoneObjFromName(const QString &zoneName) const
{
for (int i = 0; i < root->size(); i++) {
auto *node = dynamic_cast<InnerDecklistNode *>(root->at(i));
if (node->getName() == zoneName) {
if (node && node->getName() == zoneName) {
return node;
}
}
if (auto *customZone = findCustomZoneByName(zoneName)) {
return customZone;
}
return new InnerDecklistNode(zoneName, root);
}
InnerDecklistNode *DecklistNodeTree::addCustomZone(const QString &boardZoneName, const QString &zoneName)
{
if (hasZoneName(zoneName)) {
return nullptr;
}
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->findChild(boardZoneName));
if (!boardZone &&
(boardZoneName == DECK_ZONE_MAYBEBOARD || boardZoneName == DECK_ZONE_MAIN || boardZoneName == DECK_ZONE_SIDE)) {
// The boards are lazy zones: they only exist once cards or custom zones need them.
boardZone = new InnerDecklistNode(boardZoneName, root);
}
if (!boardZone) {
return nullptr;
}
return new InnerDecklistNode(zoneName, boardZone);
}
bool DecklistNodeTree::renameCustomZone(const QString &oldZoneName, const QString &newZoneName)
{
if (hasZoneName(newZoneName)) {
return false;
}
auto *zone = findCustomZoneByName(oldZoneName);
if (!zone) {
return false;
}
zone->setName(newZoneName);
return true;
}
bool DecklistNodeTree::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName)
{
auto *zone = findCustomZoneByName(zoneName);
if (!zone) {
return false;
}
auto *currentBoardZone = zone->getParent();
if (currentBoardZone && currentBoardZone->getName() == newBoardZoneName) {
return true;
}
auto *newBoardZone = dynamic_cast<InnerDecklistNode *>(root->findChild(newBoardZoneName));
if (!newBoardZone && (newBoardZoneName == DECK_ZONE_MAYBEBOARD || newBoardZoneName == DECK_ZONE_MAIN ||
newBoardZoneName == DECK_ZONE_SIDE)) {
// The boards are lazy zones: they only exist once cards or custom zones need them.
newBoardZone = new InnerDecklistNode(newBoardZoneName, root);
}
if (!newBoardZone) {
return false;
}
currentBoardZone->removeOne(zone);
newBoardZone->append(zone);
zone->setParent(newBoardZone);
return true;
}
bool DecklistNodeTree::removeCustomZone(const QString &zoneName)
{
auto *zone = findCustomZoneByName(zoneName);
if (!zone) {
return false;
}
// Detach and delete without pruning the board zone.
auto *boardZone = zone->getParent();
boardZone->removeOne(zone);
delete zone;
return true;
}
QList<const InnerDecklistNode *> DecklistNodeTree::getCustomZones(const QString &boardZoneName) const
{
QList<const InnerDecklistNode *> result;
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->findChild(boardZoneName));
if (!boardZone) {
return result;
}
for (int i = 0; i < boardZone->size(); i++) {
if (auto *customZone = dynamic_cast<InnerDecklistNode *>(boardZone->at(i))) {
result.append(customZone);
}
}
return result;
}
InnerDecklistNode *DecklistNodeTree::findCustomZoneByName(const QString &zoneName) const
{
for (int i = 0; i < root->size(); i++) {
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->at(i));
if (!boardZone) {
continue;
}
for (int j = 0; j < boardZone->size(); j++) {
auto *customZone = dynamic_cast<InnerDecklistNode *>(boardZone->at(j));
if (customZone && customZone->getName() == zoneName) {
return customZone;
}
}
}
return nullptr;
}
bool DecklistNodeTree::hasZoneName(const QString &zoneName) const
{
// The standard zones are reserved names even before they are created lazily.
if (zoneName == DECK_ZONE_MAIN || zoneName == DECK_ZONE_SIDE || zoneName == DECK_ZONE_MAYBEBOARD ||
zoneName == DECK_ZONE_TOKENS) {
return true;
}
if (root->findChild(zoneName)) {
return true;
}
return findCustomZoneByName(zoneName) != nullptr;
}

View file

@ -77,6 +77,43 @@ public:
const bool formatLegal = true);
bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr);
/**
* @brief Creates a new custom zone nested under a board zone.
*
* Custom zone names must be unique across the whole deck so that cards can be
* added to a custom zone without specifying its board zone.
*
* @param boardZoneName Name of the board zone (e.g. DECK_ZONE_MAIN).
* @param zoneName Name of the custom zone.
* @return The created zone node, or nullptr if the name is already in use.
*/
InnerDecklistNode *addCustomZone(const QString &boardZoneName, const QString &zoneName);
/**
* @brief Renames a custom zone.
* @return true on success, false if the zone was not found or the new name is taken.
*/
bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName);
/**
* @brief Moves a custom zone (and all its cards) to another board zone.
* @return true on success, false if the zone or the new board zone was not found.
*/
bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName);
/**
* @brief Removes a custom zone and all its cards.
* @return true if the zone was found and removed.
*/
bool removeCustomZone(const QString &zoneName);
/**
* @brief Gets all custom zones nested under a board zone.
* @param boardZoneName Name of the board zone.
* @return The custom zones, in insertion order.
*/
QList<const InnerDecklistNode *> getCustomZones(const QString &boardZoneName) const;
/**
* @brief Applies a function to every card in the deck tree. This can modify the cards.
*
@ -88,6 +125,8 @@ public:
private:
// Helpers for traversing the tree
InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const;
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
bool hasZoneName(const QString &zoneName) const;
};
#endif // COCKATRICE_DECKLIST_NODE_TREE_H

View file

@ -142,6 +142,12 @@ public:
return parent;
}
/** @param newParent Reparent this node. The new parent takes ownership. */
void setParent(InnerDecklistNode *newParent)
{
parent = newParent;
}
/**
* @brief Compute the depth of this node in the tree.
* @return Distance from the root (root = 0, children = 1, etc.).