Merge branch 'master' into tooomm-qt5

This commit is contained in:
tooomm 2026-08-27 06:33:34 +02:00
commit 3bc08ef94c
357 changed files with 23069 additions and 2916 deletions

View file

@ -11,6 +11,7 @@ set(HEADERS
libcockatrice/deck_list/deck_list_history_manager.h
libcockatrice/deck_list/deck_list_node_tree.h
libcockatrice/deck_list/deck_list_memento.h
libcockatrice/deck_list/playmat_resolver.h
libcockatrice/deck_list/sideboard_plan.h
)
@ -26,6 +27,7 @@ add_library(
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp
libcockatrice/deck_list/deck_list_node_tree.cpp
libcockatrice/deck_list/playmat_resolver.cpp
libcockatrice/deck_list/sideboard_plan.cpp
)
@ -33,4 +35,7 @@ add_dependencies(libcockatrice_deck_list libcockatrice_protocol)
target_include_directories(libcockatrice_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_deck_list PUBLIC libcockatrice_protocol libcockatrice_utility ${QT_CORE_MODULE})
target_link_libraries(
libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility
${QT_CORE_MODULE}
)

View file

@ -17,7 +17,7 @@ static const QString CURRENT_SIDEBOARD_PLAN_KEY = "";
bool DeckList::Metadata::isEmpty() const
{
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty();
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty();
}
DeckList::DeckList()
@ -66,6 +66,36 @@ bool DeckList::readElement(QXmlStreamReader *xml)
QString providerId = xml->attributes().value("providerId").toString();
QString cardName = xml->readElementText();
metadata.bannerCard = {cardName, providerId};
} else if (childName == "playmatCard") {
QString providerId = xml->attributes().value("providerId").toString();
bool ok;
QString marginLStr = xml->attributes().value("marginPctL").toString();
QString marginRStr = xml->attributes().value("marginPctR").toString();
QString vOffStr = xml->attributes().value("verticalOffset").toString();
QString zoomStr = xml->attributes().value("zoom").toString();
QString cardName = xml->readElementText();
PlaymatInfo playmat;
playmat.card = {cardName, providerId};
// Clamp to the same ranges as the settings dialog and the remote
// player-properties path so malformed deck files cannot produce
// degenerate art rectangles (e.g. a zoom of 0 dividing by zero).
playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95);
if (!ok) {
playmat.params.marginPctL = 0.07;
}
playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95);
if (!ok) {
playmat.params.marginPctR = 0.07;
}
playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0);
if (!ok) {
playmat.params.verticalOffset = 0.33;
}
playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0);
if (!ok) {
playmat.params.zoom = 1.0;
}
metadata.playmat = playmat;
} else if (childName == "tags") {
metadata.tags.clear(); // Clear existing tags
while (xml->readNextStartElement()) {
@ -96,6 +126,16 @@ static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metad
xml->writeAttribute("providerId", metadata.bannerCard.providerId);
xml->writeCharacters(metadata.bannerCard.name);
xml->writeEndElement();
if (!metadata.playmat.card.isEmpty()) {
xml->writeStartElement("playmatCard");
xml->writeAttribute("providerId", metadata.playmat.card.providerId);
xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4));
xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4));
xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4));
xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4));
xml->writeCharacters(metadata.playmat.card.name);
xml->writeEndElement();
}
xml->writeTextElement("comments", metadata.comments);
// Write tags
@ -365,6 +405,10 @@ bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function<QString
bool DeckList::saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards, bool slashTappedOutSplitCards) const
{
auto writeToStream = [&stream, prefixSideboardCards, slashTappedOutSplitCards](const auto node, const auto card) {
// The maybeboard is scratch space and never exported.
if (node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}
if (prefixSideboardCards && node->getName() == DECK_ZONE_SIDE) {
stream << "SB: ";
}

View file

@ -18,7 +18,7 @@
#include <QMap>
#include <QVector>
#include <QtCore/QXmlStreamReader>
#include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/utility/playmat_params.h>
class AbstractDecklistNode;
class DecklistCardNode;
@ -70,6 +70,7 @@ public:
CardRef bannerCard; ///< Optional representative card for the deck.
QStringList tags; ///< User-defined tags for deck classification.
QString lastLoadedTimestamp; ///< Timestamp string of last load.
PlaymatInfo playmat; ///< Optional playmat background for table+stack zones.
/**
* @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty.
@ -115,6 +116,10 @@ public:
{
metadata.bannerCard = _bannerCard;
}
void setPlaymat(const PlaymatInfo &_playmat = {})
{
metadata.playmat = _playmat;
}
void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString())
{
metadata.lastLoadedTimestamp = _lastLoadedTimestamp;
@ -170,6 +175,10 @@ public:
{
return metadata.bannerCard;
}
PlaymatInfo getPlaymat() const
{
return metadata.playmat;
}
QString getLastLoadedTimestamp() const
{
return metadata.lastLoadedTimestamp;

View file

@ -41,13 +41,19 @@ QList<const DecklistCardNode *> DecklistNodeTree::getCardNodes(const QSet<QStrin
{
QList<const DecklistCardNode *> result;
for (auto *zoneNode : getZoneNodes(restrictToZones)) {
for (auto *cardNode : *zoneNode) {
auto *cardCardNode = dynamic_cast<DecklistCardNode *>(cardNode);
if (cardCardNode) {
result.append(cardCardNode);
std::function<void(const InnerDecklistNode *)> collectCards = [&collectCards,
&result](const InnerDecklistNode *node) {
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<const DecklistCardNode *>(node->at(i))) {
result.append(card);
} else if (auto *inner = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
collectCards(inner);
}
}
};
for (auto *zoneNode : getZoneNodes(restrictToZones)) {
collectCards(zoneNode);
}
return result;
@ -139,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());
}
@ -160,28 +167,179 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode
void DecklistNodeTree::forEachCard(const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func) const
{
// Support for this is only possible if the internal structure
// doesn't get more complicated.
// Cards nested in custom zones are reported with their top-level board zone
// so that callers can classify cards by board (main/side/maybeboard/tokens).
std::function<void(InnerDecklistNode *, InnerDecklistNode *)> walk = [&func, &walk](InnerDecklistNode *boardZone,
InnerDecklistNode *node) {
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<DecklistCardNode *>(node->at(i))) {
func(boardZone, card);
} else if (auto *inner = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
walk(boardZone, inner);
}
}
};
for (int i = 0; i < root->size(); i++) {
InnerDecklistNode *node = dynamic_cast<InnerDecklistNode *>(root->at(i));
for (int j = 0; j < node->size(); j++) {
DecklistCardNode *card = dynamic_cast<DecklistCardNode *>(node->at(j));
func(node, card);
if (auto *zone = dynamic_cast<InnerDecklistNode *>(root->at(i))) {
walk(zone, zone);
}
}
}
/**
* 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::findBoardZone(const QString &boardZoneName) const
{
return dynamic_cast<InnerDecklistNode *>(root->findChild(boardZoneName));
}
InnerDecklistNode *DecklistNodeTree::findOrCreateBoardZone(const QString &boardZoneName)
{
auto *boardZone = findBoardZone(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);
}
return boardZone;
}
InnerDecklistNode *DecklistNodeTree::addCustomZone(const QString &boardZoneName, const QString &zoneName)
{
if (hasZoneName(zoneName)) {
return nullptr;
}
auto *boardZone = findOrCreateBoardZone(boardZoneName);
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 = findOrCreateBoardZone(newBoardZoneName);
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 = findBoardZone(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

@ -78,15 +78,57 @@ public:
bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr);
/**
* @brief Apply a function to every card in the deck tree. This can modify the cards.
* @brief Creates a new custom zone nested under a board zone.
*
* @param func Function taking (zone node, card node).
* 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.
*
* @param func Function taking (top-level board zone node, card node). Cards nested
* in custom zones are reported with their board zone.
*/
void forEachCard(const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func) const;
private:
// Helpers for traversing the tree
InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const;
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
bool hasZoneName(const QString &zoneName) const;
};
#endif // COCKATRICE_DECKLIST_NODE_TREE_H

View file

@ -0,0 +1,54 @@
#include "playmat_resolver.h"
#include <QRandomGenerator>
PlaymatInfo resolveEffectivePlaymat(const DeckList &deck,
const PlaymatInfo &force,
const QList<PlaymatInfo> &fallbackList,
PlaymatFallbackMode fallbackMode,
int rotationIndex)
{
if (!force.card.isEmpty()) {
return force;
}
const PlaymatInfo &deckPlaymat = deck.getPlaymat();
if (!deckPlaymat.card.isEmpty()) {
return deckPlaymat;
}
if (fallbackList.isEmpty()) {
return {};
}
switch (fallbackMode) {
case PlaymatFallbackModeFixed:
return fallbackList.first();
case PlaymatFallbackModeRoundRobin:
return fallbackList.at(rotationIndex % fallbackList.size());
case PlaymatFallbackModeRandom:
return fallbackList.at(QRandomGenerator::global()->bounded(fallbackList.size()));
}
return {};
}
PlaymatInfo resolvePlaymatForDeck(const DeckList &deck,
const QList<PlaymatInfo> &fallbackList,
PlaymatMode mode,
PlaymatFallbackMode fallbackBehavior,
int rotationIndex)
{
switch (mode) {
case PlaymatModeOverrideDeck: {
const DeckList emptyDeck;
return resolveEffectivePlaymat(emptyDeck, {}, fallbackList, fallbackBehavior, rotationIndex);
}
case PlaymatModeFallback:
return resolveEffectivePlaymat(deck, {}, fallbackList, fallbackBehavior, rotationIndex);
case PlaymatModeDeckOnly:
return deck.getPlaymat();
}
return {};
}

View file

@ -0,0 +1,47 @@
#ifndef COCKATRICE_PLAYMAT_RESOLVER_H
#define COCKATRICE_PLAYMAT_RESOLVER_H
#include "deck_list.h"
#include <libcockatrice/interfaces/interface_interface_settings_provider.h>
/**
* @brief Resolves the effective playmat for a deck per the resolution chain:
* force override > deck-configured playmat > fallback list > none.
*
* @param deck The deck to resolve a playmat for.
* @param force An optional user-level override; wins over everything. Pass an
* empty @ref PlaymatInfo::card to skip it.
* @param fallbackList User-level fallback playmats, consulted only when the
* deck has no configured playmat.
* @param fallbackMode How @p fallbackList is consulted (ignored when empty).
* @param rotationIndex In/out cursor for @c PlaymatFallbackModeRoundRobin;
* advanced once per call. Unused for the other modes.
* @return The effective playmat; an empty @ref PlaymatInfo::card when
* nothing in the chain resolves.
*/
PlaymatInfo resolveEffectivePlaymat(const DeckList &deck,
const PlaymatInfo &force,
const QList<PlaymatInfo> &fallbackList,
PlaymatFallbackMode fallbackMode,
int rotationIndex);
/**
* @brief Resolves the playmat to display for a deck according to the user's
* collection mode (@ref PlaymatMode), combining the deck with the
* given fallback list.
*
* @param deck The deck to resolve a playmat for.
* @param fallbackList User-level fallback playmats.
* @param mode How the collection interacts with the deck-configured playmat.
* @param fallbackBehavior How @p fallbackList is picked from.
* @param rotationIndex Cursor for @c PlaymatFallbackModeRoundRobin.
* @return The effective playmat; an empty @ref PlaymatInfo::card when nothing resolves.
*/
PlaymatInfo resolvePlaymatForDeck(const DeckList &deck,
const QList<PlaymatInfo> &fallbackList,
PlaymatMode mode,
PlaymatFallbackMode fallbackBehavior,
int rotationIndex);
#endif // COCKATRICE_PLAYMAT_RESOLVER_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.).

View file

@ -28,6 +28,8 @@ QString InnerDecklistNode::visibleNameFromName(const QString &_name)
return QObject::tr("Sideboard");
} else if (_name == DECK_ZONE_TOKENS) {
return QObject::tr("Tokens");
} else if (_name == DECK_ZONE_MAYBEBOARD) {
return QObject::tr("Maybeboard");
} else {
return _name;
}

View file

@ -24,6 +24,8 @@
#define DECK_ZONE_SIDE "side"
/** @brief Constant for the "tokens" zone name. */
#define DECK_ZONE_TOKENS "tokens"
/** @brief Constant for the "maybeboard" zone name. */
#define DECK_ZONE_MAYBEBOARD "maybeboard"
/**
* @class InnerDecklistNode