diff --git a/.ci/compile.sh b/.ci/compile.sh index 8a16d3243..bd8c900c8 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") + # PCH-aware caching is required or ccache refuses to cache any TU that + # consumes a precompiled header, silently recompiling everything on every run. + ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 04037a74e..b631f32d3 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -176,8 +176,12 @@ jobs: shell: bash run: | source .ci/docker.sh - RUN --server --debug --test --ccache "$CCACHE_SIZE" \ - --cmake-generator "$CMAKE_GENERATOR" + args=() + [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") + args+=(--ccache "$CCACHE_SIZE") + args+=(--cmake-generator "$CMAKE_GENERATOR") + + RUN --server --debug --test "${args[@]}" - name: "Build release package" id: build diff --git a/CMakeLists.txt b/CMakeLists.txt index 35eb8111b..0da073464 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ cmake_minimum_required(VERSION 3.16) # Use compiler cache (ccache) -option(USE_CCACHE "Cache the build results with ccache" OFF) +option(USE_CCACHE "Cache the build results with ccache" ON) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates @@ -39,13 +39,24 @@ else() ) endif() -if(USE_CCACHE) +# ccache does not support MSVC and must not auto-engage on Windows +# (it is installed unintentionally on the Windows CI runner). +# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows +# also opts out of ccache even though the GNUCXX branch below supports it. +if(USE_CCACHE AND NOT WIN32) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") + # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses + # to cache any TU that consumes a precompiled header, so every PCH-backed + # target recompiles from scratch on each build. + execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() +elseif(USE_CCACHE AND WIN32) + # An explicit opt-in must not disappear silently on Windows. + message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h new file mode 100644 index 000000000..cc3dd12ee --- /dev/null +++ b/cmake/pch/qtcore_pch.h @@ -0,0 +1,24 @@ +/** @file qtcore_pch.h + * @brief Precompiled header for all Qt targets (Qt Core only). + * + * Safe for every target that links Qt Core, including the headless + * Servatrice binary. Keep this header free of any widget/gui types. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h new file mode 100644 index 000000000..2c63f450e --- /dev/null +++ b/cmake/pch/qtwidgets_pch.h @@ -0,0 +1,30 @@ +/** @file qtwidgets_pch.h + * @brief Precompiled header for GUI targets (Cockatrice client, Oracle). + * + * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and + * Network layers that virtually every client translation unit re-parses. + * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). + */ + +#include "qtcore_pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 2f629fed2..b6050a1bd 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -214,6 +214,7 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_list_style_proxy.cpp src/interface/widgets/deck_editor/deck_state_manager.cpp + src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp @@ -516,6 +517,8 @@ qt6_add_executable( MANUAL_FINALIZATION ) +target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") + qt6_add_shaders( cockatrice "onboarding_shaders" diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index 0c8bdb450..fd0a12507 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
+
[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 1278737a0..1acd02a75 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, @@ -381,12 +380,10 @@ void DeckViewScene::rebuildTree() addItem(container); } - for (int j = 0; j < currentZone->size(); j++) { - auto *currentCard = dynamic_cast(currentZone->at(j)); - if (!currentCard) { - continue; - } - + // Cards in custom zones nested under a board are regular board cards in-game. + // They are collected recursively (like every other consumer) and reported with + // the top-level board zone as their origin, so that sideboard plans keep working. + for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) { for (int k = 0; k < currentCard->getNumber(); ++k) { auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName()); container->addCard(newCard); diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp index 7eb3945b3..08cb6cac9 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -12,11 +12,13 @@ TallyMenu::TallyMenu() aTallyNone = createTallyAction(TallyType::None); aTallySubtypes = createTallyAction(TallyType::Subtypes); aTallyTotalPower = createTallyAction(TallyType::TotalPower); + aTallyTotalToughness = createTallyAction(TallyType::TotalToughness); addAction(aTallyNone); addSeparator(); addAction(aTallySubtypes); addAction(aTallyTotalPower); + addAction(aTallyTotalToughness); retranslateUi(); } @@ -54,4 +56,5 @@ void TallyMenu::retranslateUi() aTallyNone->setText(tr("None")); aTallySubtypes->setText(tr("Subtypes")); aTallyTotalPower->setText(tr("Total Power")); + aTallyTotalToughness->setText(tr("Total Toughness")); } diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h index acd1daf67..11802fd20 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.h +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -24,6 +24,7 @@ private: QAction *aTallyNone = nullptr; QAction *aTallySubtypes = nullptr; QAction *aTallyTotalPower = nullptr; + QAction *aTallyTotalToughness = nullptr; QAction *createTallyAction(TallyType tallyType); }; diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp index e7a6621fa..7e05c3fb1 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -34,3 +34,31 @@ QList StatsTally::computeTotalPower(const QList &cards) QString name = QCoreApplication::translate("StatsTally", "Total Power"); return {TallyRow{name, QString::number(total)}}; } + +static int sumToughness(const QList &cards) +{ + int total = 0; + for (auto card : cards) { + QVariantList parsed = CardItem::parsePT(card->getPT()); + if (parsed.size() == 2) { + int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int + total += qMax(toughness, 0); + } + } + return total; +} + +QList StatsTally::computeTotalToughness(const QList &cards) +{ + // don't bother if none of the cards have pt + bool hasPT = + std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); }); + if (!hasPT) { + return {}; + } + + int total = sumToughness(cards); + + QString name = QCoreApplication::translate("StatsTally", "Total Toughness"); + return {TallyRow{name, QString::number(total)}}; +} diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h index 4c3d93b56..e499587eb 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.h +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -16,6 +16,14 @@ namespace StatsTally */ QList computeTotalPower(const QList &cards); +/** + * @brief Sums the toughness of all selected cards + * + * @param cards The list of selected card items to analyze. + * @return A single row containing the total, or an empty list if none of the cards have pt + */ +QList computeTotalToughness(const QList &cards); + } // namespace StatsTally #endif // COCKATRICE_STATS_TALLY_H diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp index aa2cae024..21806ee84 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -21,6 +21,8 @@ QList Tally::compute(const QList &cards, const TallyType t return SubtypeTally::countSubtypes(cards); case TallyType::TotalPower: return StatsTally::computeTotalPower(cards); + case TallyType::TotalToughness: + return StatsTally::computeTotalToughness(cards); } return {}; } diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h index 97406cddb..84c54918f 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -21,7 +21,8 @@ enum class TallyType None, Subtypes, TotalPower, - MaxValue = TotalPower // sentinel value + TotalToughness, + MaxValue = TotalToughness // sentinel value }; namespace Tally diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index 39a0c1071..f03339da8 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { + // Nested sub-zones keep their owning board's identity: the top-level call + // passes no board, so the zone's own name is used; recursive calls carry the + // owning board down so the sideboard marker survives sub-zone nesting. + const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName; + // group cards by card type and count the subtotals QMultiMap cardsByType; QMap cardTotalByType; int cardTotal = 0; + QList subZones; for (int j = 0; j < zoneNode->size(); j++) { auto *card = dynamic_cast(zoneNode->at(j)); + if (!card) { + // Cards collected in nested sub-zones are exported by recursion so + // they don't end up invisible in the plain text output. They are + // deferred until after this zone's own header and cards so they read + // as part of this zone's block. + if (auto *subZone = dynamic_cast(zoneNode->at(j))) { + subZones.append(subZone); + } + continue; + } CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); QString cardType = info ? info->getMainCardType() : "unknown"; @@ -411,25 +428,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); + saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); if (addComments) { out << "\n"; } } + + // Nested sub-zones come last, after the parent's own header and cards. + for (const auto *subZone : subZones) { + saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName); + } } void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { + if (boardZoneName == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -510,9 +532,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { + if (!node || node->isEmpty()) { + return; + } + const int totalColumns = 2; - if (node->height() == 1) { + // Dispatch children by type instead of trusting a whole-node height: a deck + // node may hold direct cards and nested zones side by side (custom zones), + // and an empty node would previously crash on at(0). + QVector cards; + QVector subZones; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + cards.append(card); + } else if (auto *zone = dynamic_cast(node->at(i))) { + subZones.append(zone); + } + } + + if (!cards.isEmpty()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -523,9 +562,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - auto *card = dynamic_cast(node->at(i)); + QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); + for (int i = 0; i < cards.size(); i++) { + const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -540,7 +579,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } else if (node->height() == 2) { + } + + for (const InnerDecklistNode *subZone : subZones) { + if (subZone->isEmpty()) { + continue; + } + QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -559,10 +604,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition(); - printDeckListNode(&cellCursor, dynamic_cast(node->at(i))); - } + QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); + printDeckListNode(&cellCursor, subZone); } cursor->movePosition(QTextCursor::End); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index ac23e1ee0..b851c6895 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -159,12 +159,13 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); static void saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); }; #endif diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp index 3f36e559c..bfbdd7e42 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp @@ -174,16 +174,18 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + addCardWidgets(QPersistentModelIndex(sourceIndex)); + } +} - // Get the card amount - int cardAmount = - sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); +void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) +{ + // Get the card amount + int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); - // Create multiple widgets for the card count - for (int copy = 0; copy < cardAmount; ++copy) { - addToLayout(constructWidgetForIndex(persistent)); - } + // Create multiple widgets for the card count + for (int copy = 0; copy < cardAmount; ++copy) { + addToLayout(constructWidgetForIndex(persistent)); } } diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h index 2308ccf8d..a3bf70981 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h @@ -35,6 +35,7 @@ public: void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected); void refreshSelectionForIndex(const QPersistentModelIndex &persistent); void clearAllDisplayWidgets(); + void addCardWidgets(const QPersistentModelIndex &persistent); DeckListModel *deckListModel; QItemSelectionModel *selectionModel; diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index eaf3a67b0..b00d9db1e 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -5,6 +5,7 @@ #include "libcockatrice/card/database/card_database_manager.h" #include +#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, // User Interaction // ===================================================================================================================== -void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) -{ - emit cardClicked(event, card, zoneName); -} - void DeckCardZoneDisplayWidget::onHover(const ExactCard &card) { emit cardHovered(card); @@ -95,12 +91,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex } auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + // Cards in a custom zone belong to that zone, not the board zone, so that + // increment/decrement/swap actions target the custom zone. + const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool(); + const QString effectiveZoneName = isCustomZone ? categoryName : zoneName; + const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) { + emit cardClicked(event, card, effectiveZoneName); + }; if (displayType == DisplayType::Overlap) { auto *displayWidget = new OverlappedCardGroupDisplayWidget( - cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria, - activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, - &DeckCardZoneDisplayWidget::onClick); + cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName, + activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - zoneName, categoryName, activeGroupCriteria, + effectiveZoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -126,24 +128,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - QSortFilterProxyModel proxy; - proxy.setSourceModel(deckListModel); - proxy.setSortRole(Qt::EditRole); - proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + if (!trackedIndex.isValid()) { + return; + } - // 1. trackedIndex is a source index → map it to proxy space - QModelIndex proxyParent = proxy.mapFromSource(trackedIndex); - - // 2. iterate children under the proxy parent - for (int i = 0; i < proxy.rowCount(proxyParent); ++i) { - QModelIndex proxyIndex = proxy.index(i, 0, proxyParent); - - // 3. map back to source - QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); - - // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + // Iterate the direct children of the tracked zone, keeping the tree view's row + // order (criteria groups first, then custom zones, both in the model's sort order). + QList rows; + for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { + rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex))); + } + for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); } } diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h index b426fca30..53f3fa7cf 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h @@ -42,7 +42,6 @@ public: void addCardsToOverlapWidget(); public slots: - void onClick(QMouseEvent *event, const ExactCard &card); void onHover(const ExactCard &card); void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget); void constructAppropriateWidget(QPersistentModelIndex index); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp index 7c782b074..00388a3cd 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName) emit cardDecremented(currentCardName(), zoneName); } +void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler) +{ + zoneMenuProvider = provider; + this->newZoneHandler = newZoneHandler; +} + void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/) { if (!current.isValid()) { @@ -142,6 +149,50 @@ void CardDatabaseView::openCustomMenu(QPoint point) [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); + if (zoneMenuProvider) { + QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone")); + const auto zoneBoards = zoneMenuProvider(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + // Boards with zones nest their children so no two menu entries + // share a visible name: "Maindeck ▸ { Maindeck (whole board), … }". + const QStringList customZones = [&zoneBoards, boardName] { + for (const auto &zoneBoard : zoneBoards) { + if (zoneBoard.first == boardName) { + return zoneBoard.second; + } + } + return QStringList(); + }(); + if (customZones.isEmpty()) { + QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(action, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + } else { + QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName)); + QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(wholeBoardAction, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + for (const QString &zoneName : customZones) { + QAction *action = boardSubmenu->addAction(zoneName); + connect(action, &QAction::triggered, this, + [this, card, zoneName] { emit cardAdded(card->getName(), zoneName); }); + } + } + } + + if (newZoneHandler) { + addToZoneMenu->addSeparator(); + + QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, [this, card] { + const QString zoneName = newZoneHandler(); + if (!zoneName.isEmpty()) { + emit cardAdded(card->getName(), zoneName); + } + }); + } + } + if (canBeCommander(*card)) { QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h index 175ec12b9..668444199 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,6 +4,7 @@ #include "../../key_signals.h" #include +#include #include class CardDatabaseModel; @@ -19,6 +20,13 @@ class CardDatabaseView : public QTreeView KeySignals searchKeySignals; CardDatabaseDisplayModel *databaseDisplayModel; + /// Provides the custom zones available in the current deck, grouped by board zone. + /// The list contains (board zone name, custom zone names) pairs for every board. + std::function>()> zoneMenuProvider; + /// Handler invoked when the user picks "New zone..." from the add-to-zone menu. + /// Returns the name of the created zone, or an empty string if creation was cancelled. + std::function newZoneHandler; + public: explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model); @@ -33,6 +41,17 @@ public: return &searchKeySignals; } + /** + * @brief Sets the provider used to populate the "Add to zone" submenu of the context menu. + * If no provider is set, the submenu is not shown. + * + * @param provider Returns the custom zones of the current deck, grouped by board zone + * @param newZoneHandler Creates a new custom zone and returns its name, or an empty string + * if creation was cancelled. The menu entry is hidden when not provided. + */ + void setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler); + signals: void cardChanged(const QString &cardName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp index 2a491de4f..6269f0323 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp @@ -1,5 +1,12 @@ #include "deck_editor_card_database_dock_widget.h" +#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" +#include "card_database_view.h" +#include "deck_state_manager.h" +#include "deck_zone_dialog.h" + +#include + DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent) { setObjectName("databaseDisplayDock"); @@ -15,6 +22,27 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck { databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel); + databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider( + [deckEditor]() -> QList> { + QList> result; + auto *deckListModel = deckEditor->deckStateManager->getModel(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); + } + return result; + }, + [this, deckEditor]() -> QString { + QString boardName; + const QString zoneName = + DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) { + return deckEditor->deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckEditor->deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; + }); + auto *frame = new QVBoxLayout; frame->setObjectName("databaseDisplayFrame"); frame->addWidget(databaseDisplayWidget); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 14defc8e9..e2175a358 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -7,15 +7,18 @@ #include "../tabs/api/commander_spellbook/commander_bracket_widget.h" #include "deck_list_style_proxy.h" #include "deck_state_manager.h" +#include "deck_zone_dialog.h" #include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -772,14 +775,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point) { + const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point)); + QMenu menu; + const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool(); + const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid(); + const bool isCardRow = + sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex); + + // Walk the row up to its top-level node to find the hosting board. Cards in + // the tokens board cannot be moved (moveCardToZone bails for it), so the + // move menu is skipped for them. + QString currentBoardName; + QModelIndex board = sourceIndex.parent(); + while (board.isValid() && board.parent().isValid()) { + board = board.parent(); + } + if (board.isValid()) { + currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + } + + if (isCardRow) { + if (currentBoardName != DECK_ZONE_TOKENS) { + addMoveToZoneMenu(&menu, sourceIndex, currentBoardName); + menu.addSeparator(); + } + } else if (isCustomZoneRow) { + const QString zoneName = + sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + + QAction *renameAction = menu.addAction(tr("&Rename zone...")); + connect(renameAction, &QAction::triggered, this, [this, zoneName] { + // The unchanged name must not validate as a duplicate. + const QString newName = + DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) { + return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate); + }); + if (!newName.isEmpty() && newName != zoneName) { + deckStateManager->renameCustomZone(zoneName, newName); + } + }); + + QMenu *boardMenu = menu.addMenu(tr("Change &board")); + addChangeBoardMenu(boardMenu, zoneName); + + QAction *deleteAction = menu.addAction(tr("&Delete zone")); + const bool zoneHasCards = getModel()->hasChildren(sourceIndex); + deleteAction->setEnabled(!zoneHasCards); + if (zoneHasCards) { + deleteAction->setToolTip(tr("Move or remove all cards first.")); + menu.setToolTipsVisible(true); + } + connect(deleteAction, &QAction::triggered, this, [this, zoneName] { + const auto result = + QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (result == QMessageBox::Yes) { + deckStateManager->removeCustomZone(zoneName); + } + }); + menu.addSeparator(); + } else if (isBoardZoneRow) { + const QString boardName = + sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + // Tokens cannot host custom zones, so only offer the action on real boards. + const bool canHostCustomZones = + boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD; + if (canHostCustomZones) { + addNewZoneAction(&menu, boardName); + menu.addSeparator(); + } + } else if (!sourceIndex.isValid()) { + addNewZoneAction(&menu); + menu.addSeparator(); + } + QAction *selectPrinting = menu.addAction(tr("Select Printing")); connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector); menu.exec(deckView->mapToGlobal(point)); } +void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, + const QModelIndex &sourceCardIndex, + const QString ¤tBoardName) +{ + // The card's current *zone*, derived with the same ancestor walk as + // DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the + // top-level board/zone): a card inside "Removal" under the maindeck lives in + // "Removal", not "main". Comparing against that instead of the board keeps + // the enabled state and the same-zone no-op consistent with the move logic. + QString currentZoneName; + for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { + if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) { + currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + break; + } + } + + const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName, + const QString &label, bool enabled) { + QAction *action = targetMenu->addAction(label); + action->setEnabled(enabled); + if (enabled) { + connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] { + deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName); + }); + } + }; + + const auto tree = deckStateManager->getDeckListShared()->getTree(); + + QMenu *moveMenu = menu->addMenu(tr("Move to &zone")); + + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName); + const auto customZones = tree->getCustomZones(boardName); + + // Boards with zones nest their children so no two menu entries share a + // visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }". + // The board the card already lives on is marked instead of offered. + if (!customZones.isEmpty()) { + QMenu *boardSubmenu = moveMenu->addMenu(boardLabel); + addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName); + for (const auto *customZone : customZones) { + addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(), + customZone->getName() != currentZoneName); + } + } else { + addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName); + } + } + + moveMenu->addSeparator(); + + QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here...")); + connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] { + // Resolve the card's identity before creating the zone: + // createNewCustomZone rebuilds the model tree, so sourceCardIndex's + // internal pointer is freed by the time it would be used. + const QString cardName = + sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + const QString providerId = + sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); + const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER) + .data(Qt::DisplayRole) + .toString(); + + const QString zoneName = createNewCustomZone(currentBoardName); + if (!zoneName.isEmpty()) { + // Re-find the card: the old index is no longer safe since rows were + // rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern. + const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber); + if (refreshed.isValid()) { + deckStateManager->moveCardToZone(refreshed, zoneName); + } + } + }); +} + +void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName) +{ + const auto tree = deckStateManager->getDeckListShared()->getTree(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + + // The board currently holding the zone is marked instead of offered. + // Duplicate names cannot come up through the editor, so this doubles as + // the uniqueness guard for imported decks. + bool holdsTheZone = false; + for (const auto *customZone : tree->getCustomZones(boardName)) { + if (customZone->getName() == zoneName) { + holdsTheZone = true; + break; + } + } + if (holdsTheZone) { + action->setCheckable(true); + action->setChecked(true); + continue; + } + + connect(action, &QAction::triggered, this, + [this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); }); + } +} + +void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName) +{ + QAction *newZoneAction = menu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, + [this, initialBoardName] { createNewCustomZone(initialBoardName); }); +} + +QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName) +{ + QString boardName; + const QString zoneName = + DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) { + return deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; +} + void DeckEditorDeckDockWidget::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 9db01e2e5..1e5f4e677 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -102,6 +103,11 @@ private: [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); + void addChangeBoardMenu(QMenu *menu, const QString &zoneName); + QString createNewCustomZone(const QString &initialBoardName = {}); + void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {}); + private slots: void decklistCustomMenu(QPoint point); void updateCard(QModelIndex, const QModelIndex ¤t); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index eda741728..e563729a4 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -2,6 +2,7 @@ #include #include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), @@ -307,6 +308,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx) return offsetCountAtIndex(idx, -1); } +bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName) +{ + if (!idx.isValid()) { + return false; + } + + // Only actual card rows can be moved. Group or zone rows report an + // aggregate amount and must never be deleted by this operation. + if (!idx.data(DeckRoles::IsCardRole).toBool()) { + return false; + } + + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); + int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); + + if (copies <= 0) { + return false; + } + + // Tokens only live in the tokens zone and cannot be moved into decks. + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); + if (info && info->getIsToken()) { + return false; + } + + // Determine the zone the card currently lives in: the enclosing custom + // zone, or the nearest top-level zone (board zone or legacy zone). + QString currentZoneName; + for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { + bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool(); + if (isCustomZone || !ancestor.parent().isValid()) { + currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + break; + } + } + + if (currentZoneName == targetZoneName) { + return false; + } + + QString reason = tr("Moved %1 × \"%2\" (%3) to %4") + .arg(copies) + .arg(cardName) + .arg(providerId) + .arg(InnerDecklistNode::visibleNameFromName(targetZoneName)); + + return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) { + if (!model->removeRow(idx.row(), idx.parent())) { + return false; + } + + if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) { + for (int i = 0; i < copies; ++i) { + model->addCard(card, targetZoneName); + } + } else { + for (int i = 0; i < copies; ++i) { + model->addPreferredPrintingCard(cardName, targetZoneName, true); + } + } + + return true; + }); +} + +bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName) +{ + const QString trimmedZoneName = zoneName.trimmed(); + if (trimmedZoneName.isEmpty()) { + return false; + } + + QString reason = + tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName)); + + return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) { + return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr; + }); +} + +bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) +{ + const QString trimmedNewZoneName = newZoneName.trimmed(); + if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) { + return false; + } + + QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName); + + return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) { + return tree->renameCustomZone(oldZoneName, trimmedNewZoneName); + }); +} + +bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) +{ + const auto *tree = deckList->getTree(); + + // Locate the zone through the tree's own lookup, which walks every top-level + // zone (not just the standard boards) and covers the same-board no-op below. + const auto *zone = tree->findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + // Same-board moves are no-ops and must not pollute the history. + const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString(); + if (currentBoardName == newBoardZoneName) { + return true; + } + + // Zone names are deck-unique among zones created through this manager, so a + // same-named zone on the target board can only come from an imported deck. + // Refuse the move instead of silently stacking same-named zones. + for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) { + if (targetZone->getName() == zoneName) { + return false; + } + } + + QString reason = + tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName)); + + return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) { + return tree->moveCustomZone(zoneName, newBoardZoneName); + }); +} + +bool DeckStateManager::removeCustomZone(const QString &zoneName) +{ + QString reason = tr("Deleted zone \"%1\"").arg(zoneName); + + return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); }); +} + +QString DeckStateManager::validateNewZoneName(const QString &zoneName) const +{ + if (zoneName.trimmed().isEmpty()) { + return tr("Enter a zone name."); + } + + const QString trimmedZoneName = zoneName.trimmed(); + + // The standard zone names are reserved even before they exist. + if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE || + trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) { + return tr("This name is reserved."); + } + + const auto *tree = deckList->getTree(); + + // Reuse the tree's own uniqueness contract: any top-level zone and any + // custom zone on *every* board claims the name (hasZoneName also reserves + // the standard board names, which we already rejected with a dedicated + // message above). Scanning only the standard boards here would miss a + // custom zone an imported deck carries under `tokens`. + if (tree->hasZoneName(trimmedZoneName)) { + return tr("A zone with this name already exists."); + } + + return {}; +} + bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset) { if (!idx.isValid()) { @@ -367,6 +532,25 @@ void DeckStateManager::requestHistorySave(const QString &reason) historyManager->save(deckList->createMemento(reason)); } +bool DeckStateManager::modifyTree(const QString &reason, const std::function &operation) +{ + DeckListMemento memento = deckList->createMemento(reason); + bool success = operation(deckList->getTree()); + + if (success) { + historyManager->save(memento); + deckListModel->rebuildTree(); + deckList->refreshDeckHash(); + emit deckListModel->deckHashChanged(); + // removeCustomZone can drop whole card sets the model never notified + // about (rebuildTree emits no cardNodesChanged), so tell the consumers. + emit deckListModel->cardNodesChanged(); + doCardModified(); + } + + return success; +} + /** * @brief Handles updating state and emitting signals whenever the cards are modified */ diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index b9c99903e..2c8b34a39 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,6 +5,7 @@ #include "deck_list_model.h" #include +#include #include class DeckListHistoryManager; @@ -236,6 +237,68 @@ public: */ bool decrementCountAtIndex(const QModelIndex &idx); + /** + * @brief Moves all copies of the card at the given index to the given zone. + * No-ops if the index is invalid, not a card node, the card is a token, or the + * card is already in the target zone. + * Saves the operation to history if successful. + * + * @param idx The model index of the card to move + * @param targetZoneName The zone to move the card to (board zone or custom zone name) + * @return Whether the operation was successfully performed + */ + bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName); + + /** + * @brief Creates a new custom zone nested under a board zone. + * Saves the operation to history if successful. + * + * @param boardZoneName The board zone to nest the custom zone under + * @param zoneName The name of the new custom zone. Gets trimmed and must be + * unique across the deck. + * @return Whether the zone was created + */ + bool createCustomZone(const QString &boardZoneName, const QString &zoneName); + + /** + * @brief Renames a custom zone. + * Saves the operation to history if successful. + * + * @param oldZoneName The current name of the custom zone + * @param newZoneName The new name. Gets trimmed and must be unique across the deck. + * @return Whether the rename succeeded + */ + bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); + + /** + * @brief Moves a custom zone (and its cards) to a different board zone. + * Same-board moves succeed without creating a history entry. + * Saves the operation to history if successful. + * + * @param zoneName The custom zone to move + * @param newBoardZoneName The board zone to move the custom zone under + * @return Whether the move succeeded + */ + bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); + + /** + * @brief Removes a custom zone and all its cards. + * Saves the operation to history if successful. + * + * @param zoneName The custom zone to remove + * @return Whether the zone was removed + */ + bool removeCustomZone(const QString &zoneName); + + /** + * @brief Checks whether a candidate name is usable for a new custom zone. + * + * @param zoneName The candidate name + * @return An empty string when the name is usable, otherwise a user-facing + * error message describing the problem + */ + [[nodiscard]] QString validateNewZoneName(const QString &zoneName) const; + /** * Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager. * @param steps Number of steps to undo. @@ -257,6 +320,7 @@ public slots: private: bool offsetCountAtIndex(const QModelIndex &idx, int offset); + bool modifyTree(const QString &reason, const std::function &operation); void doCardModified(); void doMetadataModified(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp new file mode 100644 index 000000000..9a0be2570 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp @@ -0,0 +1,145 @@ +#include "deck_zone_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DeckZoneDialog::DeckZoneDialog(QWidget *parent, + const QString &initialBoardName, + const std::function &_nameValidator, + bool _allowBoardSelection) + : QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection) +{ + nameLabel = new QLabel(this); + nameEdit = new QLineEdit(this); + nameEdit->setMaxLength(MAX_NAME_LENGTH); + + errorLabel = new QLabel(this); + errorLabel->hide(); + + boardLabel = new QLabel(this); + boardCombo = new QComboBox(this); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + // Use the icon overload explicitly so `boardName` lands in the user data role + // (visible text is applied below in retranslateUi). The two-argument form + // addItem({}, boardName) would be ambiguous and resolve to the icon overload + // with empty user data, yielding empty entries and an empty getBoardName(). + boardCombo->addItem({}, {}, boardName); + } + if (!initialBoardName.isEmpty()) { + int idx = boardCombo->findData(initialBoardName); + if (idx != -1) { + boardCombo->setCurrentIndex(idx); + } + } + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(nameLabel); + layout->addWidget(nameEdit); + layout->addWidget(errorLabel); + if (allowBoardSelection) { + layout->addWidget(boardLabel); + layout->addWidget(boardCombo); + } else { + boardLabel->hide(); + boardCombo->hide(); + } + layout->addWidget(buttonBox); + + retranslateUi(); + + connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); }); + validateName(); + + nameEdit->setFocus(); +} + +QString DeckZoneDialog::getZoneName() const +{ + return nameEdit->text().trimmed(); +} + +QString DeckZoneDialog::getBoardName() const +{ + return boardCombo->currentData().toString(); +} + +void DeckZoneDialog::setZoneName(const QString &zoneName) +{ + nameEdit->setText(zoneName); + nameEdit->selectAll(); +} + +void DeckZoneDialog::changeEvent(QEvent *event) +{ + QDialog::changeEvent(event); + + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } +} + +void DeckZoneDialog::retranslateUi() +{ + setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone")); + + nameLabel->setText(tr("Zone &name:")); + nameLabel->setBuddy(nameEdit); + + boardLabel->setText(tr("&Parent zone:")); + boardLabel->setBuddy(boardCombo); + + for (int i = 0; i < boardCombo->count(); i++) { + boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString())); + } +} + +void DeckZoneDialog::validateName() +{ + const QString zoneName = nameEdit->text().trimmed(); + QString error; + if (zoneName.isEmpty()) { + error = tr("Enter a zone name."); + } else if (nameValidator) { + error = nameValidator(zoneName); + } + + errorLabel->setText(error); + errorLabel->setVisible(!error.isEmpty()); + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty()); +} + +QString DeckZoneDialog::promptForNewZone(QWidget *parent, + const QString &initialBoardName, + QString *chosenBoardName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, initialBoardName, nameValidator); + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + if (chosenBoardName) { + *chosenBoardName = dialog.getBoardName(); + } + return dialog.getZoneName(); +} + +QString DeckZoneDialog::promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, {}, nameValidator, false); + dialog.setZoneName(currentZoneName); + return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString(); +} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h new file mode 100644 index 000000000..6f55617a8 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h @@ -0,0 +1,123 @@ +/** + * @file deck_zone_dialog.h + * @ingroup DeckEditorWidgets + * @brief Shared dialog for creating custom deck zones. + */ + +#ifndef DECK_ZONE_DIALOG_H +#define DECK_ZONE_DIALOG_H + +#include +#include +#include +#include + +class QComboBox; +class QDialogButtonBox; +class QLabel; +class QLineEdit; +class QWidget; + +/** + * @brief Modal dialog asking for the name and parent zone of a new custom deck zone. + * + * Menus construct the dialog transiently around exec(), so validation state only + * ever reflects the name currently typed. + */ +class DeckZoneDialog : public QDialog +{ + Q_OBJECT + +public: + /** + * @brief Constructs the dialog and runs the initial validation pass. + * + * @param parent The parent widget for the dialog + * @param initialBoardName The board zone to preselect in the combo. Unknown names + * fall back to main. + * @param _nameValidator Given the trimmed candidate name, returns an empty string + * when it is usable, otherwise a user-facing error message. May be empty. + * @param _allowBoardSelection When false the parent-zone combo is hidden and the + * dialog acts as a rename prompt for an existing zone. + */ + explicit DeckZoneDialog(QWidget *parent = nullptr, + const QString &initialBoardName = {}, + const std::function &_nameValidator = {}, + bool _allowBoardSelection = true); + + /** + * @brief The trimmed zone name entered by the user. + */ + [[nodiscard]] QString getZoneName() const; + + /** + * @brief The internal name of the board zone selected in the combo. + */ + [[nodiscard]] QString getBoardName() const; + + /** + * @brief Prefills the name field, e.g. with the current name when renaming. + * + * @param zoneName The text to put into the name field, selected for quick editing + */ + void setZoneName(const QString &zoneName); + + /** + * @brief Prompts the user for a new custom zone name and the board zone to nest it under. + * + * Convenience wrapper that runs DeckZoneDialog modally. + * + * @param parent The parent widget for the dialog + * @param initialBoardName The board zone to preselect in the dialog. Unknown names fall + * back to main. + * @param chosenBoardName (out) The internal name of the board zone the user chose + * @param nameValidator Optional validator forwarded to the dialog + * @return The trimmed zone name, or an empty string if the user cancelled + */ + static QString promptForNewZone(QWidget *parent, + const QString &initialBoardName, + QString *chosenBoardName, + const std::function &nameValidator = {}); + + /** + * @brief Prompts the user for a new name for an existing custom zone. + * + * Same inline validation as promptForNewZone, but without a parent-zone picker. + * + * @param parent The parent widget for the dialog + * @param currentZoneName The current name, prefilled for editing + * @param nameValidator Validator deciding whether a candidate name is usable. It sees + * the current name too, so callers wanting to allow unchanged names must + * special-case that themselves. + * @return The trimmed new name, or an empty string if the user cancelled + */ + static QString promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator = {}); + +protected: + void changeEvent(QEvent *event) override; + +private: + /** + * @brief Sets every user-visible string. Runs on construction and on runtime + * language changes. + */ + void retranslateUi(); + + /** + * @brief Validates the current input, toggling Ok and the inline error label. + */ + void validateName(); + + QLabel *nameLabel; + QLineEdit *nameEdit; + QLabel *errorLabel; + QLabel *boardLabel; + QComboBox *boardCombo; + QDialogButtonBox *buttonBox; + std::function nameValidator; + bool allowBoardSelection; +}; + +#endif // DECK_ZONE_DIALOG_H diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 209a30642..0f43893d3 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -4,6 +4,7 @@ #include "../../../../client/settings/shortcuts_settings.h" #include "../../cards/card_info_display_widget.h" #include "../../deck_editor/deck_state_manager.h" +#include "../../deck_editor/deck_zone_dialog.h" #include "../../filters/filter_builder.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/cards/card_info_frame_widget.h" @@ -84,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame() connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this, &TabDeckEditorVisual::showPrintingSelector); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo); + tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); }); centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); @@ -269,6 +271,19 @@ bool TabDeckEditorVisual::actSaveDeckAs() return result; } +/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */ +QString TabDeckEditorVisual::createNewZone() +{ + QString boardName; + const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) { + return deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; +} + /** @brief Refreshes keyboard shortcuts for this tab from settings. */ void TabDeckEditorVisual::refreshShortcuts() { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index 21335d2d0..fb09578c4 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -165,6 +165,12 @@ public slots: */ bool actSaveDeckAs() override; + /** + * @brief Prompts for and creates a new custom deck zone. + * @return The name of the created zone, or an empty string if creation was cancelled. + */ + QString createNewZone(); + private: /** * @brief Sets the deck for this tab and selects the sub-tab to open on diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 0cdf60d5d..76bbf344b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -89,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseView->setItemDelegate(nullptr); databaseView->setVisible(false); + // Without a deck model there is nothing to add cards to, so the zone menu stays hidden. + if (deckListModel) { + databaseView->setZoneMenuProvider( + [deckListModel]() -> QList> { + QList> result; + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); + } + return result; + }, + [this] { return newZoneCreator ? newZoneCreator() : QString(); }); + } + searchEdit->setTreeView(databaseView); searchEdit->installEventFilter(databaseView->getKeySignals()); @@ -195,6 +209,11 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event) initializeFilters(); } +void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function &creator) +{ + newZoneCreator = creator; +} + void VisualDatabaseDisplayWidget::retranslateUi() { databaseLoadIndicator->setText(tr("Loading database ...")); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 6e4d87876..d161ce362 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,12 @@ public: void sortCardList(const QStringList &properties, Qt::SortOrder order) const; void setDeckList(const DeckList &new_deck_list_model); + /** + * @brief Sets the callback used to create a custom zone from the add-to-zone menu. + * The callback returns the name of the created zone, or an empty string if creation was cancelled. + */ + void setNewZoneCreator(const std::function &creator); + CardDatabaseDisplayModel *getDatabaseDisplayModel() { return databaseDisplayModel; @@ -106,6 +113,7 @@ private: VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseView *databaseView; + std::function newZoneCreator; QList *cards; QVBoxLayout *mainLayout; QScrollArea *scrollArea; diff --git a/format.sh b/format.sh index ca3557ea7..9e3a6069b 100755 --- a/format.sh +++ b/format.sh @@ -18,6 +18,7 @@ include=("cockatrice/src" \ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ +"cmake/pch" \ "tests") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index af1193f26..5d91cd233 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -115,6 +115,25 @@ public: */ QList getCustomZones(const QString &boardZoneName) const; + /** + * @brief Checks whether a zone name is taken anywhere in the deck. + * + * Covers the standard board names and any top-level or nested custom zone. + * @param zoneName The checked name. + * @return true if the name is reserved or already in use. + */ + bool hasZoneName(const QString &zoneName) const; + + /** + * @brief Finds a custom zone anywhere in the deck by name. + * + * Walks the children of every top-level zone, so a zone nested under any + * board (and not just the standard ones) is found. + * @param zoneName The zone name to find. + * @return The matching zone node, or nullptr if none exists. + */ + InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const; + /** * @brief Applies a function to every card in the deck tree. This can modify the cards. * @@ -128,8 +147,6 @@ private: 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 diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index ec860dc56..d082b3cca 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method) } } +const QList &InnerDecklistNode::boardZoneNames() +{ + static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), + QString(DECK_ZONE_MAYBEBOARD)}; + return names; +} + QString InnerDecklistNode::getVisibleName() const { return visibleNameFromName(name); @@ -87,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { + if (isEmpty()) { + return 1; + } return at(0)->height() + 1; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 906ed6cb5..0d454c11e 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -18,6 +18,9 @@ #include "abstract_deck_list_node.h" +#include +#include + /** @brief Constant for the "main" deck zone name. */ #define DECK_ZONE_MAIN "main" /** @brief Constant for the "sideboard" zone name. */ @@ -118,6 +121,13 @@ public: */ static QString visibleNameFromName(const QString &_name); + /** + * @brief The standard board zone names, in display order. + * + * @return main, side and maybeboard. + */ + static const QList &boardZoneNames(); + /** * @brief Get this node’s display-friendly name. * @return Human-readable name (zone/group name). diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 25e8e97db..aaf391c03 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include static peg::parser search(R"( @@ -19,7 +20,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') [:] FlexStringValue +SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) OracleQuery <- 'o' [:] MatcherString @@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ +SetExpression <- NumericOperator ws? String + NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -101,12 +104,25 @@ static void setupParserRules() return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); + if (sv.choice() == 1) { + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + QList sets = x->getSets().keys(); - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); + }; + } + + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + const auto &sets = x->getSets().values(); + auto matchesSet = [&](const PrintingInfo &printing) { + return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); + }; + return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { + return std::any_of(printings.begin(), printings.end(), matchesSet); + }); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -247,40 +263,54 @@ static void setupParserRules() return QString::fromStdString(std::string(sv.sv())); }; - search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto arg = std::any_cast(sv[1]); - const auto op = std::any_cast(sv[0]); + search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { + const auto op = QString::fromStdString(std::string(sv.sv())); if (op == ">") { - return [=](const int s) { return s > arg; }; + return [=](const int s, const int arg) { return s > arg; }; } if (op == ">=") { - return [=](const int s) { return s >= arg; }; + return [=](const int s, const int arg) { return s >= arg; }; } if (op == "<") { - return [=](const int s) { return s < arg; }; + return [=](const int s, const int arg) { return s < arg; }; } if (op == "<=") { - return [=](const int s) { return s <= arg; }; + return [=](const int s, const int arg) { return s <= arg; }; } if (op == "=") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == ":") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == "!=") { - return [=](const int s) { return s != arg; }; + return [=](const int s, const int arg) { return s != arg; }; } - return [](int) { return false; }; + return [](int, int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { - return QString::fromStdString(std::string(sv.sv())); + search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto comparer = std::any_cast(sv[0]); + const auto arg = std::any_cast(sv[1]); + return [=](int s) { return comparer(s, arg); }; + }; + + search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto comparer = std::any_cast(sv[0]); + const auto setCode = std::any_cast(sv[1]); + const auto allSets = CardDatabaseManager::getInstance()->getSetList(); + for (auto &set : allSets) { + if (set->getShortName() == setCode) { + const int releaseDate = set->getReleaseDate().toJulianDay(); + return [=](int s) { return comparer(s, releaseDate); }; + } + } + return [](int) { return false; }; }; search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 71a99f7b5..a058f7d07 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,6 +22,7 @@ typedef CardInfoPtr CardData; typedef std::function Filter; typedef std::function StringMatcher; typedef std::function NumberMatcher; +typedef std::function NumberComparer; namespace peg { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index d4aee3686..a6ab2a204 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + deck_list_sort_filter_proxy_model.cpp ) target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 9b43281c1..76afca0c4 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,7 +66,8 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - //! \todo Better sanity checking. + // Non-card children are custom zones; they are mirrored in a single + // pass below so each is mirrored exactly once. if (currentCard == nullptr) { continue; } @@ -82,8 +83,19 @@ void DeckListModel::rebuildTree() new DecklistModelCardNode(currentCard, groupNode); } + + // Custom zones nested under the board zone are mirrored as-is, with their + // cards as direct children (no further grouping). + DeckListModelCustomZones::mirrorCustomZones(currentZone, node); } + // The shadow tree was built in deck file order. Apply the active sort while + // the reset is still open so every consumer (tree view and visual editor) + // sees the canonical order from the start. sortShadowTree emits no signals, + // which is only valid before endResetModel closes the reset. + root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName); + sortShadowTree(root, lastKnownOrder); + endResetModel(); refreshCardFormatLegalities(); @@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; + case DeckRoles::IsCustomZoneRole: + return DeckListModelCustomZones::isCustomZone(group); + default: return {}; } @@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } + case DeckRoles::IsCustomZoneRole: { + return false; + } + default: { return {}; } @@ -327,6 +346,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) return false; } + // Custom zone rows are managed through the deck tree, never removed as model rows. + for (int i = 0; i < count; i++) { + if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) { + return false; + } + } + beginRemoveRows(parent, row, row + count - 1); for (int i = 0; i < count; i++) { AbstractDecklistNode *toDelete = node->takeAt(row); @@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - if (node->empty() && (node != root)) { + // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. + if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - auto *newNode = dynamic_cast(parent->findChild(name)); + // Group lookups must not resolve a mirrored custom zone that shares the name. + auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -365,24 +393,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName, const QString &providerId, const QString &cardNumber) const { - InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName)); - if (!zoneNode) { - return nullptr; - } - CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); if (!info) { return nullptr; } - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); - if (!groupNode) { - return nullptr; + // 1. Board zone lookup: search the criteria groups, then the custom zones + // nested under the board. + if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) { + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) { + if (auto *card = dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } + + for (auto *child : *zoneNode) { + if (!DeckListModelCustomZones::isCustomZone(child)) { + continue; + } + auto *customZone = dynamic_cast(child); + if (!customZone) { + continue; + } + if (auto *card = dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } } - return dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + // 2. Custom zone lookup by name (custom zone names are deck-unique). + if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) { + return dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + } + + return nullptr; } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } - InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); - CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); + InnerDecklistNode *cardParent = nullptr; - const QModelIndex parentIndex = nodeToIndex(groupNode); - auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( + auto *boardNode = dynamic_cast(root->findChild(zoneName)); + auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName); + + // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row + // index is only usable as a deck-tree position while both sides have the same + // direct-children shape. When they diverge, the card is appended to the deck + // zone instead of being written out of range. + InnerDecklistNode *deckCardParent = nullptr; + bool customZoneNeedsAppend = false; + + if (boardNode) { + // Board zone: cards are grouped by the active criteria. + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + cardParent = createNodeIfNeeded(groupCriteria, boardNode); + } else if (customZoneNode) { + // Custom zone: cards live flat inside the zone. + cardParent = customZoneNode; + auto *listRoot = deckList->getTree()->getRoot(); + for (int i = 0; i < listRoot->size(); ++i) { + auto *boardZone = dynamic_cast(listRoot->at(i)); + if (!boardZone) { + continue; + } + deckCardParent = dynamic_cast(boardZone->findChild(zoneName)); + if (deckCardParent) { + break; + } + } + // A deck custom zone holding nested sub-zones mirrors with flattened rows, + // so a shadow row index does not map onto its direct children. + if (deckCardParent) { + for (int i = 0; i < deckCardParent->size(); ++i) { + if (dynamic_cast(deckCardParent->at(i))) { + customZoneNeedsAppend = true; + break; + } + } + } + } else { + // Not present in the shadow tree. The deck tree may still hold a custom + // zone that has not been mirrored (callers can add a zone and then a + // card without a rebuild). Check before falling back to creating a + // top-level zone the deck does not actually have. + auto *listRoot = deckList->getTree()->getRoot(); + bool hasDeckZone = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *boardZone = dynamic_cast(listRoot->at(i))) { + // Only real zones count: a card sitting directly under the board + // shares the name comparison but is not a zone, and treating it as + // one would recurse forever without mirroring anything. + if (dynamic_cast(boardZone->findChild(zoneName))) { + hasDeckZone = true; + break; + } + } + } + + if (hasDeckZone) { + rebuildTree(); + return addCard(card, zoneName); + } + + // Unknown zone: create a top-level zone (legacy behavior). + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + auto *newZone = createNodeIfNeeded(zoneName, root); + cardParent = createNodeIfNeeded(groupCriteria, newZone); + } + + const QModelIndex parentIndex = nodeToIndex(cardParent); + auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber( card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); bool cardNodeAdded = false; if (!cardNode) { // Determine the correct index - int insertRow = findSortedInsertRow(groupNode, cardInfo); + int insertRow = findSortedInsertRow(cardParent, cardInfo); + int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); endInsertRows(); cardNodeAdded = true; @@ -576,21 +690,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const return createIndex(node->getParent()->indexOf(node), 0, node); } +/** + * @brief Sorts a freshly built shadow subtree without emitting model signals. + * + * Used by rebuildTree while the model reset is still open (emitting layout + * changes during a reset is invalid). Reorders every node just like + * sortHelper does, but ignores the movement mapping because there are no + * persistent indices established yet. + */ +void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) +{ + // The mapping is not needed: fresh shadow nodes have no persistent indices yet. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + + for (int i = node->size() - 1; i >= 0; --i) { + if (auto *subNode = dynamic_cast(node->at(i))) { + sortShadowTree(subNode, order); + } + } +} + void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { - // Sort children of node and save the information needed to - // update the list of persistent indexes. - QVector> sortResult = node->sort(order); + // Sort children (custom zones always sorted after groups within a board) and + // use the movement mapping to update the list of persistent indices. + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); QModelIndexList from, to; int columns = columnCount(); - for (int i = sortResult.size() - 1; i >= 0; --i) { - const int fromRow = sortResult[i].first; - const int toRow = sortResult[i].second; - AbstractDecklistNode *temp = node->at(toRow); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + AbstractDecklistNode *temp = node->at(finalRow); for (int j = 0; j < columns; ++j) { - from << createIndex(fromRow, j, temp); - to << createIndex(toRow, j, temp); + from << createIndex(preSortRow, j, temp); + to << createIndex(finalRow, j, temp); } } changePersistentIndexList(from, to); @@ -704,6 +838,15 @@ QList DeckListModel::getZones() const return zones; } +QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const +{ + QStringList zoneNames; + for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) { + zoneNames.append(customZone->getName()); + } + return zoneNames; +} + static int maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 209ec8c42..09600ca67 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,6 +1,8 @@ #ifndef DECKLISTMODEL_H #define DECKLISTMODEL_H +#include "deck_list_model_custom_zones.h" + #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h> #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> #include @@ -30,7 +32,8 @@ enum { IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ DepthRole, /**< Depth level within the deck's grouping hierarchy. */ - IsLegalRole /**< Whether the card is legal in the current deck format. */ + IsLegalRole, /**< Whether the card is legal in the current deck format. */ + IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ }; } // namespace DeckRoles @@ -391,6 +394,14 @@ public: */ [[nodiscard]] QList getZones() const; + /** + * @brief Gets the names of the custom zones nested under the given board zone. + * + * @param boardZoneName The board zone to query (main/side/maybeboard) + * @return The custom zone names, in deck order + */ + [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const; + private: QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */ InnerDecklistNode *root; /**< Root node of the model tree. */ @@ -427,6 +438,7 @@ private: void emitRecursiveUpdates(const QModelIndex &index); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); + void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order); template T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp new file mode 100644 index 000000000..1dc745e63 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -0,0 +1,152 @@ +#include "deck_list_model_custom_zones.h" + +#include "deck_list_model.h" + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> +#include +#include + +namespace DeckListModelCustomZones +{ + +bool isCustomZone(const AbstractDecklistNode *node) +{ + return dynamic_cast(node) != nullptr; +} + +namespace +{ + +/** + * @brief Flattens every card under @p zone into @p shadowZone, preserving order. + * + * Custom zones mirror as a single row level: cards nested in sub-zones of any + * depth are added as direct children of the mirrored zone so no card is left + * without a model row. + */ +void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone) +{ + for (int k = 0; k < zone->size(); k++) { + if (auto *zoneCard = dynamic_cast(zone->at(k))) { + new DecklistModelCardNode(zoneCard, shadowZone); + } else if (auto *subZone = dynamic_cast(zone->at(k))) { + flattenCards(subZone, shadowZone); + } + } +} + +} // namespace + +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) +{ + for (int j = 0; j < deckBoardZone->size(); j++) { + auto *customZone = dynamic_cast(deckBoardZone->at(j)); + if (!customZone) { + continue; + } + + auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone); + flattenCards(customZone, shadowZone); + } +} + +InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name) +{ + for (int i = 0; i < parent->size(); i++) { + AbstractDecklistNode *child = parent->at(i); + if (isCustomZone(child)) { + continue; + } + auto *group = dynamic_cast(child); + if (group && group->getName() == name) { + return group; + } + } + return nullptr; +} + +DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName) +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +namespace +{ + +/** + * @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping. + */ +QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QList> mapping; + mapping.reserve(node->size()); + for (int i = 0; i < node->size(); ++i) { + mapping.append({sortResult[i].first, i}); + } + return mapping; +} + +/** + * @brief Sorts a board zone's children, then stably moves custom zones to the end. + * + * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. + */ +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QVector groups; + QVector customZones; + QHash preSortRowOf; + + groups.reserve(node->size()); + customZones.reserve(node->size()); + + for (int i = 0; i < node->size(); ++i) { + AbstractDecklistNode *child = node->at(i); + preSortRowOf.insert(child, sortResult[i].first); + if (isCustomZone(child)) { + customZones.append(child); + } else { + groups.append(child); + } + } + + QVector ordered = groups + customZones; + for (int i = 0; i < ordered.size(); ++i) { + node->replace(i, ordered[i]); + } + + QList> mapping; + mapping.reserve(ordered.size()); + for (int i = 0; i < ordered.size(); ++i) { + mapping.append({preSortRowOf.value(ordered[i]), i}); + } + return mapping; +} + +} // namespace + +QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +{ + const bool isBoardZone = (node != root) && (node->getParent() == root); + return isBoardZone ? boardSort(node, order) : plainSort(node, order); +} + +} // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h new file mode 100644 index 000000000..518a9e1d2 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -0,0 +1,98 @@ +#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H +#define DECK_LIST_MODEL_CUSTOM_ZONES_H + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h> +#include +#include +#include + +/** + * @class DecklistModelSubZoneNode + * @ingroup DeckModels + * @brief Model node representing a custom zone nested under a board zone. + * + * Custom zones group cards by user-defined names (e.g. "Removal", "Utility") + * inside a board zone. They are mirrored from the underlying deck tree so that + * they can be told apart from criteria group nodes by type. + */ +class DecklistModelSubZoneNode : public InnerDecklistNode +{ +public: + using InnerDecklistNode::InnerDecklistNode; +}; + +/** + * @namespace DeckListModelCustomZones + * @ingroup DeckModels + * @brief Tree-level helpers for the deck list model's custom-zone shadow nodes. + * + * The deck list model keeps a second "shadow" tree of InnerDecklistNode that + * mirrors the canonical deck tree for grouping and sorting. Custom zones add a + * layer of bookkeeping to that shadow tree: they must be mirrored alongside + * criteria groups, always sort after the groups within a board, and be + * resolvable by deck-unique name. + * + * This namespace centralizes every "what is / where is a custom zone" decision + * so the model itself only wires the results into Qt model signals. + */ +namespace DeckListModelCustomZones +{ + +/** + * @brief Whether the given node is a custom zone (as opposed to a criteria group). + */ +[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node); + +/** + * @brief Finds a criteria-group child of @p parent by name, skipping custom zones. + * + * The shadow tree keeps criteria groups and mirrored custom zones as siblings + * under a board zone, and `InnerDecklistNode::findChild` matches both by name. + * Group lookups must not resolve a custom zone that happens to share the group + * name (e.g. a zone called "Creature"), so this searches only non-custom + * children. + * + * @param parent The shadow node whose children are searched. + * @param name The group name to find. + * @return The matching group node, or nullptr if none exists. + */ +[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name); + +/** + * @brief Mirrors the custom zones of a deck board zone into its shadow board node. + * + * Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone + * with its cards as direct (un-grouped) children. + * + * @param deckBoardZone The board zone in the canonical deck tree. + * @param shadowBoardZone The matching board zone in the model's shadow tree. + */ +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone); + +/** + * @brief Finds a custom zone in the shadow tree by deck-unique name. + * @param root Root of the shadow tree. + * @param zoneName The custom zone name to find. + * @return The matching custom zone node, or nullptr if not found. + */ +[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName); + +/** + * @brief Sorts a shadow node's children, keeping a board's custom zones last. + * + * Sorting alone would interleave custom zones with criteria groups by name, but + * custom zones must always stay after the groups within a board, regardless of + * name. This applies the sort and, for board zones, stably moves the custom + * zones to the end. + * + * @param root Root of the shadow tree (used to classify board zones). + * @param node The shadow node whose children are reordered. + * @param order Sort order to apply. + * @return A list of (preSortRow, finalRow) pairs describing how each node moved. + */ +[[nodiscard]] QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); + +} // namespace DeckListModelCustomZones + +#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 6a29b6935..68c4709bb 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,6 +23,7 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp + src/raw_json_scanner.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp @@ -112,6 +113,8 @@ qt6_add_executable( MANUAL_FINALIZATION ) +target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") + # ------------------------ # Link libraries # ------------------------ diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 49577baf0..d745b250c 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -44,26 +45,23 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(const QByteArray &data) +bool OracleImporter::readSetsFromByteArray(QByteArray data) { - QJsonParseError error; - auto doc = QJsonDocument::fromJson(data, &error); - if (error.error != QJsonParseError::NoError) { - qDebug() << "error: QJsonDocument::fromJson():" << error.errorString(); + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error); + if (error.isError()) { + qDebug() << "error: RawJson::scanSetRanges():" << error.message; return false; } - auto setsObj = doc.object().value("data").toObject(); - QList newSetList; + newSetList.reserve(ranges.size()); - for (auto it = setsObj.constBegin(); it != setsObj.constEnd(); ++it) { - QJsonObject setObj = it.value().toObject(); - QString shortName = setObj.value("code").toString().toUpper(); - QString longName = setObj.value("name").toString(); - QJsonArray setCards = setObj.value("cards").toArray(); - QString setType = setObj.value("type").toString(); - QDate releaseDate = QDate::fromString(setObj.value("releaseDate").toString(), Qt::ISODate); + for (const RawJson::SetRange &range : ranges) { + QString shortName = range.code.toUpper(); + QString longName = range.name; + QString setType = range.type; + QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate); CardSet::Priority priority = getSetPriority(setType, shortName); // capitalize set type if (setType.length() > 0) { @@ -83,7 +81,9 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) } setType = setType.trimmed(); } - newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); + SetToDownload set(shortName, longName, priority, setType, releaseDate); + set.setRawRange(range.dataRange); + newSetList.append(set); } std::sort(newSetList.begin(), newSetList.end()); @@ -92,6 +92,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) return false; } allSets = newSetList; + rawSetsData = std::move(data); return true; } @@ -550,22 +551,16 @@ int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - // Pre-allocate the cards hash to avoid rehashing during import. The hash - // is keyed by distinct card name rather than by printings: AllPrintings - // ships ~100k printings for ~35k names, so reserving the printing count - // would overallocate ~3x (against this stack's RAM goal). Collecting - // distinct names is cheap — one pass over the already-parsed name fields. - { - QSet distinctNames; - for (const SetToDownload &curSetToParse : allSets) { - for (const QJsonValue &cardValue : curSetToParse.getCards()) { - distinctNames.insert(cardValue.toObject().value("name").toString()); - } - } - cards.reserve(distinctNames.size()); - // The set goes out of scope here, handing the ~35k name QStrings back - // to the allocator before the (memory-heavy) import loop starts. + // Pre-allocate the cards hash to avoid rehashing during import. Keys are + // distinct card names while raw ranges only count printings (AllPrintings + // ~100k printings vs ~35k names), so this over-reserves somewhat; an exact + // distinct-name count would require eagerly parsing, which the lazy reader + // deliberately avoids. It's a capacity hint, so the overshoot is harmless. + int estimatedCards = 0; + for (const SetToDownload &curSetToParse : allSets) { + estimatedCards += curSetToParse.getRawRange().cardCount; } + cards.reserve(estimatedCards); // add an empty set for tokens CardSetPtr tokenSet = @@ -578,11 +573,44 @@ int OracleImporter::startImport() CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getReleaseDate(), curSetToParse.getPriority()); + + // parse only this set's slice of the raw document so the whole JSON tree is + // never kept in memory at once + const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); + const qsizetype rangeEnd = rawRange.start + rawRange.length; + if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { + // rawSetsData is cleared by releaseSetData() while SetToDownload copies + // taken from getSets() keep their ranges, and nothing else enforces the + // pairing — so never index past the buffer on stale/mismatched ranges. + qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; + ++setIndex; + emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); + continue; + } + // sliced() shares the buffer instead of deep-copying the slice; the largest + // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. + const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); + QJsonParseError parseError; + const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" + << parseError.errorString(); + ++setIndex; + // Keep the progress accounting honest: a set that failed to parse + // still advanced the index, so report it (with zero imported cards) + // rather than letting SaveSetsPage's bar stall per failed set. + emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); + continue; + } + + // Only add the set to the database once its slice parsed cleanly; + // a set that fails here must not persist as an empty set in cards.xml. if (!sets.contains(newSet->getShortName())) { sets.insert(newSet->getShortName(), newSet); } - int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); + const QJsonArray setCards = setDoc.object().value("cards").toArray(); + int numCardsInSet = importCardsFromSet(newSet, setCards); ++setIndex; @@ -605,6 +633,7 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr void OracleImporter::releaseSetData() { allSets.clear(); + rawSetsData.clear(); } void OracleImporter::clear() @@ -612,4 +641,5 @@ void OracleImporter::clear() sets.clear(); cards.clear(); allSets.clear(); + rawSetsData.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 52a7cd349..8cb30ca40 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,6 +1,9 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H +#include "raw_json_scanner.h" + +#include #include #include #include @@ -46,10 +49,12 @@ class SetToDownload { private: QString shortName, longName; - QJsonArray cards; QDate releaseDate; QString setType; CardSet::Priority priority; + // Byte range of this set's object within the importer's raw JSON text. Parsing + // one set at a time keeps peak memory low instead of holding the whole document. + RawJson::SetDataRange rawRange; public: const QString &getShortName() const @@ -60,10 +65,6 @@ public: { return longName; } - const QJsonArray &getCards() const - { - return cards; - } const QString &getSetType() const { return setType; @@ -76,16 +77,23 @@ public: { return priority; } + const RawJson::SetDataRange &getRawRange() const + { + return rawRange; + } SetToDownload(QString _shortName, QString _longName, - QJsonArray _cards, CardSet::Priority _priority, QString _setType = QString(), const QDate &_releaseDate = QDate()) - : shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), - releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate), + setType(std::move(_setType)), priority(_priority) { } + void setRawRange(const RawJson::SetDataRange &_rawRange) + { + rawRange = _rawRange; + } bool operator<(const SetToDownload &set) const { return longName.compare(set.longName, Qt::CaseInsensitive) < 0; @@ -141,6 +149,12 @@ private: QList allSets; + /** + * The raw JSON text of the source document, retained for lazy per-set + * parsing during startImport(). Frees the card data as each set is imported. + */ + QByteArray rawSetsData; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, @@ -153,7 +167,11 @@ signals: public: explicit OracleImporter(QObject *parent = nullptr); - bool readSetsFromByteArray(const QByteArray &data); + /** + * Scans the given JSON document for set metadata. Takes the data by value so + * the wizard can hand over its decompressed buffer without copying it. + */ + bool readSetsFromByteArray(QByteArray data); int startImport(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); @@ -169,6 +187,10 @@ public: { return allSets; } + const QByteArray &getRawSetsData() const + { + return rawSetsData; + } void releaseSetData(); void clear(); }; diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp new file mode 100644 index 000000000..db972491c --- /dev/null +++ b/oracle/src/raw_json_scanner.cpp @@ -0,0 +1,621 @@ +#include "raw_json_scanner.h" + +#include + +namespace +{ + +// Nesting cap matching QJsonDocument's limit, so a pathologically deep document +// fails shallowly instead of overflowing the stack through the recursive +// skipValue/skipArray/skipObject walk (Qt's parser caps at 1024 for the same +// reason and reports DeepNesting). +constexpr int kMaxNestingDepth = 1024; + +inline bool isWhitespace(char c) +{ + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +const char *skipWhitespace(const char *p, const char *end) +{ + while (p < end && isWhitespace(*p)) { + ++p; + } + return p; +} + +inline bool isHexDigit(char c) +{ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +inline quint8 hexValue(char c) +{ + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + return c - 'A' + 10; +} + +/** + * @brief Skips past a JSON string without decoding it, validating escapes. + * @param p In: pointing at the opening quote. Out: pointing past the closing quote. + */ +bool skipString(const char *&p, const char *end) +{ + ++p; // opening quote + for (;;) { + const void *quote = memchr(p, '"', static_cast(end - p)); + if (!quote) { + return false; // unterminated string + } + // Backslash escapes can only appear before the closing quote, so bound + // the scan to the string extent instead of the rest of the document. + const void *backslash = memchr(p, '\\', static_cast(static_cast(quote) - p)); + if (!backslash) { + p = static_cast(quote) + 1; + return true; + } + const char *b = static_cast(backslash); + if (end - b < 2) { + return false; + } + const char escaped = b[1]; + if (escaped == 'u') { + if (end - b < 6) { + return false; + } + quint32 codepoint = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(b[2 + i])) { + return false; + } + codepoint = codepoint * 16 + hexValue(b[2 + i]); + } + p = b + 6; + if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + // expect the low-surrogate escape for the second half + if (end - p < 6 || p[0] != '\\' || p[1] != 'u') { + return false; // unpaired high surrogate + } + quint32 low = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[2 + i])) { + return false; + } + low = low * 16 + hexValue(p[2 + i]); + } + if (low < 0xDC00 || low > 0xDFFF) { + return false; + } + p += 6; + } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return false; // unpaired low surrogate + } + continue; + } + switch (escaped) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + p = b + 2; + continue; + default: + return false; // invalid escape + } + } +} + +/** + * @brief Decodes a JSON string into @p out, validating it as it goes. + * @param p In: pointing at the opening quote. Out: pointing past the closing quote. + */ +bool decodeString(const char *&p, const char *end, QString &out) +{ + out.clear(); + QByteArray utf8; + auto flush = [&out, &utf8]() { + if (!utf8.isEmpty()) { + out += QString::fromUtf8(utf8); + utf8.clear(); + } + }; + + ++p; // opening quote + while (p < end) { + const char c = *p; + if (c == '\\') { + flush(); + ++p; // escaped character + if (p >= end) { + return false; + } + const char escaped = *p; + if (escaped == 'u') { + ++p; // first hex digit + if (p + 4 > end) { + return false; + } + quint32 codepoint = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[i])) { + return false; + } + codepoint = codepoint * 16 + hexValue(p[i]); + } + p += 4; + if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + // expect a low-surrogate escape for the second half + if (p + 6 > end || p[0] != '\\' || p[1] != 'u') { + return false; // unpaired high surrogate + } + quint32 low = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[2 + i])) { + return false; + } + low = low * 16 + hexValue(p[2 + i]); + } + if (low < 0xDC00 || low > 0xDFFF) { + return false; + } + out += QChar(codepoint); + out += QChar(low); + p += 6; + } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return false; // unpaired low surrogate + } else { + out += QChar(codepoint); + } + continue; + } + switch (escaped) { + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + default: + return false; + } + ++p; + continue; + } + if (c == '"') { + ++p; + flush(); + return true; + } + // Deliberately accept unescaped control characters (e.g. a tab inside + // a set name): QJsonDocument and skipString accept them too, so + // rejecting them here would fail the whole document on a byte that + // Qt is fine with — the very total-failure mode this scanner avoids. + utf8 += c; + ++p; + } + return false; +} + +/** + * @brief Reads a set-metadata field, tolerating null and non-string values. + * + * A set's metadata may carry null or non-string values in otherwise-valid + * payloads ("releaseDate": null, "type": 7). The token itself was already + * structurally validated by skipValue, so a non-string value is accepted and + * leaves @p out at its default (empty) — one bad set must not abort the + * import of every other set in the document. + */ +bool decodeStringMember(const char *&fs, const char *&fe, QString &out) +{ + if (fs >= fe) { + return false; + } + if (*fs != '"') { + return true; + } + return decodeString(fs, fe, out); +} + +bool matchLiteral(const char *&p, const char *end, const char *literal, int length) +{ + if (end - p < length || memcmp(p, literal, static_cast(length)) != 0) { + return false; + } + const char *after = p + length; + if (after < end && (QChar::isLetter(*after) || QChar::isDigit(*after) || *after == '_')) { + return false; + } + p = after; + return true; +} + +bool skipNumber(const char *&p, const char *end) +{ + // JSON number: -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? + if (p < end && *p == '-') { + ++p; + } + if (p < end && *p == '0') { + ++p; + } else if (p < end && *p >= '1' && *p <= '9') { + ++p; + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } else { + return false; + } + if (p < end && *p == '.') { + ++p; + if (p >= end || !QChar::isDigit(*p)) { + return false; + } + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } + if (p < end && (*p == 'e' || *p == 'E')) { + ++p; + if (p < end && (*p == '+' || *p == '-')) { + ++p; + } + if (p >= end || !QChar::isDigit(*p)) { + return false; + } + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } + return true; +} + +bool skipValue(const char *&p, const char *end, int depth); +bool skipObject(const char *&p, const char *end, int depth); +bool skipArray(const char *&p, const char *end, int depth); + +bool skipPrimitive(const char *&p, const char *end) +{ + if (p >= end) { + return false; + } + const char c = *p; + if (c == '"') { + return skipString(p, end); + } + if (c == 't') { + return matchLiteral(p, end, "true", 4); + } + if (c == 'f') { + return matchLiteral(p, end, "false", 5); + } + if (c == 'n') { + return matchLiteral(p, end, "null", 4); + } + if (c == '-' || (c >= '0' && c <= '9')) { + return skipNumber(p, end); + } + return false; +} + +bool skipObject(const char *&p, const char *end, int depth) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '{' + p = skipWhitespace(p, end); + if (p < end && *p == '}') { + ++p; + return true; + } + for (;;) { + p = skipWhitespace(p, end); + if (p >= end || *p != '"') { + return false; + } + if (!skipString(p, end)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end || *p != ':') { + return false; + } + ++p; + if (!skipValue(p, end, depth - 1)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == '}') { + ++p; + return true; + } + return false; + } +} + +bool skipArray(const char *&p, const char *end, int depth) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '[' + p = skipWhitespace(p, end); + if (p < end && *p == ']') { + ++p; + return true; + } + for (;;) { + if (!skipValue(p, end, depth - 1)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == ']') { + ++p; + return true; + } + return false; + } +} + +bool skipValue(const char *&p, const char *end, int depth) +{ + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + const char c = *p; + if (c == '{') { + // pass depth through: skipObject consumes the single decrement for this level + return skipObject(p, end, depth); + } + if (c == '[') { + return skipArray(p, end, depth); + } + // a primitive is a leaf, so it never wastes a nesting level + return skipPrimitive(p, end); +} + +/** + * @brief Iterates the members of the object starting at @p p. + * + * For each member invokes @p memberCallback with the key and the byte range of + * its value. Advancing @p p is unaffected by the callback. + */ +template bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '{' + p = skipWhitespace(p, end); + if (p < end && *p == '}') { + ++p; + return true; + } + for (;;) { + p = skipWhitespace(p, end); + if (p >= end || *p != '"') { + return false; + } + QString key; + if (!decodeString(p, end, key)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end || *p != ':') { + return false; + } + ++p; + const char *valueStart = skipWhitespace(p, end); + const char *valueEnd = valueStart; + if (!skipValue(valueEnd, end, depth - 1)) { + return false; + } + if (!memberCallback(key, valueStart, valueEnd)) { + return false; + } + p = valueEnd; + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == '}') { + ++p; + return true; + } + return false; + } +} + +// Counts the direct elements of an array value; returns -1 if the array is malformed. +int countArrayElements(const char *p, const char *end, int depth) +{ + if (depth <= 0) { + return -1; // nest deeper than the cap + } + ++p; // '[' + p = skipWhitespace(p, end); + int count = 0; + if (p < end && *p == ']') { + return 0; + } + for (;;) { + if (!skipValue(p, end, depth - 1)) { + return -1; + } + ++count; + p = skipWhitespace(p, end); + if (p >= end) { + return -1; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == ']') { + return count; + } + return -1; + } +} + +} // namespace + +namespace RawJson +{ + +QList scanSetRanges(const QByteArray &json, ScanError *error) +{ + QList ranges; + if (error) { + *error = ScanError{}; + } + + const auto fail = [&](const QString &message) -> QList { + if (error) { + error->message = message; + } + return {}; + }; + + const char *begin = json.constData(); + const char *end = begin + json.size(); + if (begin >= end) { + return fail(QStringLiteral("empty JSON document")); + } + + const char *p = skipWhitespace(begin, end); + if (p >= end || *p != '{') { + return fail(QStringLiteral("top-level JSON must be an object")); + } + + bool foundData = false; + bool malformedSetData = false; + + const auto topLevelCallback = [&](const QString &key, const char *valueStart, const char *valueEnd) { + if (key == QStringLiteral("data")) { + foundData = true; + if (valueStart >= valueEnd || *valueStart != '{') { + malformedSetData = true; + return false; + } + const char *setP = valueStart; + const bool ok = forEachObjectMember(setP, valueEnd, kMaxNestingDepth - 1, + [&](const QString &setCode, const char *setStart, const char *setEnd) { + if (setStart >= setEnd || *setStart != '{') { + malformedSetData = true; + return false; + } + SetRange range; + range.dataRange.start = setStart - begin; + range.dataRange.length = setEnd - setStart; + range.code = setCode; + + const char *memberP = setStart; + const bool metaOk = forEachObjectMember( + memberP, setEnd, kMaxNestingDepth - 2, + [&](const QString &field, const char *fs, const char *fe) { + if (field == QStringLiteral("code")) { + return decodeStringMember(fs, fe, range.code); + } + if (field == QStringLiteral("name")) { + return decodeStringMember(fs, fe, range.name); + } + if (field == QStringLiteral("type")) { + return decodeStringMember(fs, fe, range.type); + } + if (field == QStringLiteral("releaseDate")) { + return decodeStringMember(fs, fe, range.releaseDate); + } + if (field == QStringLiteral("cards")) { + if (fs >= fe) { + return false; + } + if (*fs != '[') { + // e.g. "cards": null — treat as an empty array, + // matching Qt's tolerance. + return true; + } + range.dataRange.cardCount = + countArrayElements(fs, fe, kMaxNestingDepth - 2); + return range.dataRange.cardCount >= 0; + } + return true; + }); + if (!metaOk) { + malformedSetData = true; + return false; + } + ranges.append(range); + return true; + }); + if (!ok) { + malformedSetData = true; + return false; + } + } + return true; + }; + + if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback)) { + return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON")); + } + p = skipWhitespace(p, end); + if (p != end) { + return fail(QStringLiteral("trailing content after top-level JSON object")); + } + if (!foundData) { + return fail(QStringLiteral("missing \"data\" object")); + } + if (ranges.isEmpty()) { + return fail(QStringLiteral("no sets found in \"data\"")); + } + return ranges; +} + +} // namespace RawJson \ No newline at end of file diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h new file mode 100644 index 000000000..f6e3a4647 --- /dev/null +++ b/oracle/src/raw_json_scanner.h @@ -0,0 +1,76 @@ +#ifndef RAW_JSON_SCANNER_H +#define RAW_JSON_SCANNER_H + +#include +#include +#include + +namespace RawJson +{ + +/** + * @brief The byte extent of a set's object inside the scanned document, plus + * the size of its cards array. This is the slice SetToDownload needs for lazy + * per-set parsing; the metadata strings live in SetRange alongside it. + */ +struct SetDataRange +{ + /** @brief Byte offset of the set's object within the scanned buffer. */ + qsizetype start = -1; + /** @brief Byte length of the set's object, including the surrounding braces. */ + qsizetype length = 0; + /** @brief Number of entries in the set's "cards" array. */ + int cardCount = 0; +}; + +struct SetRange +{ + /** @brief The byte slice of this set within the document. */ + SetDataRange dataRange; + QString code; + QString name; + QString type; + QString releaseDate; +}; + +struct ScanError +{ + bool isError() const + { + return !message.isEmpty(); + } + QString message; +}; + +/** + * @brief Scans a full MTGJSON document without materializing the JSON tree. + * + * Splits the top-level "data" object into per-set byte ranges and reads each + * set's metadata directly from the raw bytes. The oracle importer can then + * parse one set at a time during import, keeping peak memory far below a single + * QJsonDocument::fromJson() over the whole file. + * + * The whole document is structurally validated while scanning (strings, + * escapes, braces, and a trailing-content check) and nesting depth is capped at + * 1024 to match QJsonDocument, so pathologically deep documents fail shallowly + * instead of exhausting the stack. Verdicts agree with QJsonDocument::fromJson + * on structurally malformed input; unlike Qt, string metadata fields + * ("name", "type", "releaseDate", "code") tolerate null / non-string values by + * defaulting to empty rather than rejecting the whole document, so one broken + * set cannot abort the import of the rest. + * + * Following QJsonDocument::fromJson's convention, the parsed ranges are + * returned by value and any failure is reported through the @p error out + * parameter. + * + * @param json The raw MTGJSON document bytes. + * @param error Out parameter. Set to an error ScanError when the document + * cannot be parsed, otherwise left empty. Passing a null + * pointer disables error reporting. + * @return The detected per-set ranges, or an empty list on failure. + */ +QList scanSetRanges(const QByteArray &json, ScanError *error = nullptr); + +} // namespace RawJson + +#endif // RAW_JSON_SCANNER_H \ No newline at end of file diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index aba63800c..5d8089ad1 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -95,6 +95,8 @@ set(DESKTOPDIR # Build servatrice binary and link it add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES}) +target_precompile_headers(servatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") + if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD") target_link_libraries( servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 34784538b..18ab60d06 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,6 +114,7 @@ target_link_libraries( add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) +add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt new file mode 100644 index 000000000..e3096c559 --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -0,0 +1,33 @@ +add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_custom_zones_test gtest) +endif() + +target_link_libraries( + deck_list_model_custom_zones_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) + +add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_zone_integration_test gtest) +endif() + +target_link_libraries( + deck_list_model_zone_integration_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp new file mode 100644 index 000000000..7f4ab81e1 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -0,0 +1,276 @@ +/** + * @file deck_list_model_custom_zones_test.cpp + * @brief Tests for the deck list model's custom-zone shadow-tree helpers. + * + * DeckListModelCustomZones centralizes every "what is / where is a custom zone" + * decision for the model's shadow tree: type testing, mirroring from the deck + * tree, name lookup, and the sort-with-custom-zones-last ordering. These tests + * exercise that logic directly on hand-built shadow trees, independent of the + * full model and card database machinery. + */ + +#include +#include +#include +#include + +namespace +{ + +DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number) +{ + // The underlying data node is detached; only the model wrapper is attached to the shadow tree. + auto *data = new DecklistCardNode(name, number, nullptr); + return new DecklistModelCardNode(data, parent); +} + +QStringList childNames(const InnerDecklistNode *node) +{ + QStringList names; + for (int i = 0; i < node->size(); ++i) { + names.append(node->at(i)->getName()); + } + return names; +} + +} // namespace + +// ===================================================================================================================== +// isCustomZone +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + auto *zone = new DecklistModelSubZoneNode("Removal", board); + + auto *card = cardNode(group, "A", 1); + + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card)); + EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone)); +} + +// ===================================================================================================================== +// findSubZoneByName +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards) +{ + InnerDecklistNode root; + auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root); + new DecklistModelSubZoneNode("Removal", main); + new DecklistModelSubZoneNode("Utility", side); + new InnerDecklistNode("Plain", main); // not a custom zone + + auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal"); + ASSERT_NE(removal, nullptr); + EXPECT_EQ(removal->getName(), QString("Removal")); + + auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility"); + ASSERT_NE(utility, nullptr); + EXPECT_EQ(utility->getName(), QString("Utility")); + + // Names are deck-unique; a plain group or built-in board is not matched. + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr); +} + +// ===================================================================================================================== +// mirrorCustomZones +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat) +{ + // Deck-tree board zone: one direct card plus one nested custom zone. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + // Only the custom zone is mirrored as a sub-zone; the direct card is not. + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop) +{ + // A board zone with only direct cards has nothing to mirror. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + EXPECT_EQ(shadowBoard->size(), 0); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones) +{ + // Cards deeper than one level under a custom zone still get a model row. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone); + auto *deeper = new InnerDecklistNode("Deeper", deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Both cards are flattened into the mirrored zone, preserving order. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +// ===================================================================================================================== +// findGroupChild +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + new DecklistModelSubZoneNode("Creature", board); + + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group); + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board); +} + +// ===================================================================================================================== +// sortWithCustomZonesLast +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // Groups sort first (by name), then custom zones (by name), always after groups. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); + + // Some non-identity movement occurred. + EXPECT_FALSE(mapping.isEmpty()); +} + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder); + + // Groups still lead (descending), custom zones still last. + EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"})); +} + +TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + + QList originalOrder; + auto *g0 = new InnerDecklistNode("Creature", board); + originalOrder.append(g0); + auto *z0 = new DecklistModelSubZoneNode("Zebra", board); + originalOrder.append(z0); + auto *g1 = new InnerDecklistNode("Instant", board); + originalOrder.append(g1); + auto *z1 = new DecklistModelSubZoneNode("Alpha", board); + originalOrder.append(z1); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // The mapping reports, for each final row, the original row of the node now sitting there. + ASSERT_EQ(mapping.size(), board->size()); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + ASSERT_GE(preSortRow, 0); + ASSERT_LT(preSortRow, originalOrder.size()); + EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow; + } + + // Final order sanity: groups first in name order, then custom zones. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); +} + +TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) +{ + // A non-board node (e.g. a group whose children are cards) is sorted plainly; + // custom zones are not a special case there. Cards sort by name. + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + cardNode(group, "Swords", 1); + cardNode(group, "Bolt", 3); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder); + EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"})); + ASSERT_EQ(mapping.size(), 2); + EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1 + EXPECT_EQ(mapping[0].second, 0); + EXPECT_EQ(mapping[1].first, 0); + EXPECT_EQ(mapping[1].second, 1); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp new file mode 100644 index 000000000..a4562c95d --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,283 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +int totalCustomZoneRows(const DeckListModel &model) +{ + int count = 0; + const int rootRows = model.rowCount(QModelIndex()); + for (int r = 0; r < rootRows; ++r) { + const QModelIndex board = model.index(r, 0, QModelIndex()); + const int childRows = model.rowCount(board); + for (int c = 0; c < childRows; ++c) { + const QModelIndex child = model.index(c, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + ++count; + } + } + } + return count; +} + +QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName) +{ + for (int r = 0; r < model.rowCount(QModelIndex()); ++r) { + const QModelIndex idx = model.index(r, 0, QModelIndex()); + if (idx.data(DeckRoles::IsCardRole).toBool()) { + continue; + } + const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + if (name == boardName) { + return idx; + } + } + return {}; +} + +QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board) +{ + for (int r = 0; r < model.rowCount(board); ++r) { + const QModelIndex child = model.index(r, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + return child; + } + } + return {}; +} + +} // namespace + +// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the +// deck tree. These verify the source data a freshly-created zone populates. + +TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"})); +} + +TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); +} + +// Mirroring regression: rebuildTree must mirror each custom zone exactly once. +TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // One direct mainboard card plus two nested custom zones. + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Swords to Plowshares", 1, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + + model.rebuildTree(); + + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); + EXPECT_EQ(totalCustomZoneRows(model), 2); +} + +// ===================================================================================================================== +// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival. +// ===================================================================================================================== + +TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); + ASSERT_TRUE(added.isValid()); + + // The card is a direct child of the mirrored custom zone, not a new top-level zone. + const QModelIndex zoneParent = added.parent(); + ASSERT_TRUE(zoneParent.isValid()); + EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Removal")); + + // No "Removal" top-level zone appeared in the deck tree. + auto *listRoot = tree->getRoot(); + bool topLevelRemoval = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *zone = dynamic_cast(listRoot->at(i))) { + topLevelRemoval |= zone->getName() == "Removal"; + } + } + EXPECT_FALSE(topLevelRemoval); +} + +TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // The zone exists on the deck tree but the shadow tree has never mirrored it. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); + ASSERT_TRUE(added.isValid()); + + const QModelIndex zoneParent = added.parent(); + ASSERT_TRUE(zoneParent.isValid()); + EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Removal")); +} + +TEST(DeckListModelZoneIntegration, AddCardCreatesGroupSeparatelyFromSameNamedZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // A custom zone named exactly like a grouping criterion. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Creature"), nullptr); + model.rebuildTree(); + + CardInfoPtr bear = CardInfo::newInstance("Grizzly Bears"); + bear->setProperty(Mtg::MainCardType, "Creature"); + + QModelIndex added = model.addCard(ExactCard(bear), DECK_ZONE_MAIN); + ASSERT_TRUE(added.isValid()); + + // The card lands in a *group* node called "Creature", not swallowed by the custom zone. + const QModelIndex groupParent = added.parent(); + ASSERT_TRUE(groupParent.isValid()); + EXPECT_FALSE(groupParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(groupParent.sibling(groupParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Creature")); + + // The board keeps both rows: the "Creature" group and the "Creature" custom zone. + const QModelIndex boardIndex = groupParent.parent(); + ASSERT_TRUE(boardIndex.isValid()); + EXPECT_EQ(model.rowCount(boardIndex), 2); +} + +TEST(DeckListModelZoneIntegration, FindCardResolvesCardInsideCustomZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + // findCard resolves through the card database; register the card we add. + const QString cardName = "Swords to Plowshares"; + CardInfoPtr info = CardInfo::newInstance(cardName); + CardDatabaseManager::getInstance()->addCard(info); + + QModelIndex added = model.addCard(ExactCard(info), "Removal"); + ASSERT_TRUE(added.isValid()); + + QModelIndex found = model.findCard(cardName, "Removal"); + EXPECT_TRUE(found.isValid()); + EXPECT_EQ(found, added); +} + +TEST(DeckListModelZoneIntegration, RemoveRowsRefusesCustomZoneRow) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + model.rebuildTree(); + + const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); + ASSERT_TRUE(mainIndex.isValid()); + const QModelIndex zoneRow = findZoneRow(model, mainIndex); + ASSERT_TRUE(zoneRow.isValid()); + + EXPECT_FALSE(model.removeRow(zoneRow.row(), zoneRow.parent())); + EXPECT_EQ(model.rowCount(mainIndex), 2); // the zone survives, alongside the card group +} + +TEST(DeckListModelZoneIntegration, EmptyCustomZoneSurvivesMirrorAndPruning) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // An empty custom zone must be mirrored (the stack deliberately keeps it alive). + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); + ASSERT_TRUE(mainIndex.isValid()); + EXPECT_EQ(model.rowCount(mainIndex), 1); + EXPECT_TRUE(findZoneRow(model, mainIndex).isValid()); +} + +// Regression: a board card named like the requested zone must not be mistaken for +// a zone. Previously `findChild` matched any child by name, so a mainboard card +// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and +// recurse through rebuildTree forever. +TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt"); + ASSERT_TRUE(added.isValid()); +} + +// Regression: adding to a custom zone that holds a nested sub-zone mirrored the +// nested cards as flattened shadow rows, so the sorted shadow row index pointed +// past the deck zone's direct children. The card must be appended to the deck +// zone instead of being written out of range. +TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(removal, nullptr); + auto *deeper = new InnerDecklistNode("Deeper", removal); + new DecklistCardNode("Lightning Bolt", 2, deeper, -1); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal"); + ASSERT_TRUE(added.isValid()); + ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp index a5148621d..801f226a9 100644 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -213,6 +213,42 @@ TEST(DeckListZones, MoveCustomZoneMovesCards) EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); } +TEST(DeckListZones, MoveCustomZoneFailsForUnknownBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); + + // The zone is still under main. + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); +} + +// Regression: findCustomZoneByName walks every top-level zone, so a custom zone +// an imported deck carries under a non-standard board (tokens) is still found and +// movable. The pre-fix manager-level moveCustomZone only scanned the standard +// boards and returned false for these with no feedback. +TEST(DeckListZones, MoveCustomZoneNestedUnderTokensBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + auto *root = tree->getRoot(); + + auto *tokens = new InnerDecklistNode(DECK_ZONE_TOKENS, root); + auto *removal = new InnerDecklistNode("Removal", tokens); + new DecklistCardNode("Lightning Bolt", 2, removal, -1); + + EXPECT_TRUE(tree->findCustomZoneByName("Removal")); + EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_TOKENS, "Lightning Bolt")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); +} + TEST(DeckListZones, RemoveCustomZoneRemovesCards) { DeckList deck; diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index d126390c7..9bc5ee5be 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -11,7 +11,7 @@ add_test(NAME parse_cipt_test COMMAND parse_cipt_test) # Oracle importer unit tests add_executable( oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - oracle_importer_test.cpp + ../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp ) if(NOT GTEST_FOUND) @@ -25,10 +25,33 @@ target_link_libraries( add_test(NAME oracle_importer_test COMMAND oracle_importer_test) -# Oracle importer benchmark tests (manual, not run in CI) +# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark +# can download and decompress whatever AllPrintings format the default URL selects. +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") +endif() + add_executable( - oracle_importer_benchmark_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp - ../../oracle/src/parsehelpers.cpp oracle_importer_benchmark_test.cpp + oracle_importer_benchmark_test + ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES} ) if(NOT GTEST_FOUND) @@ -36,6 +59,11 @@ if(NOT GTEST_FOUND) endif() target_link_libraries( - oracle_importer_benchmark_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} + oracle_importer_benchmark_test + libcockatrice_card + libcockatrice_interfaces + Threads::Threads + ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} + ${_ORACLE_BENCH_EXTRA_LIBRARIES} ) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp index b3cc55319..633c9e40a 100644 --- a/tests/oracle/oracle_importer_benchmark_test.cpp +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -1,13 +1,33 @@ #include "../../oracle/src/oracleimporter.h" #include "gtest/gtest.h" +#include +#include #include #include +#include +#include #include #include #include +#include +#include +#include +#include +#include #include +#if defined(HAS_LZMA) +#include "../../oracle/src/lzma/decompress.h" +#endif +#if defined(HAS_ZLIB) +#include "../../oracle/src/zip/unzip.h" +#endif +#if defined(Q_OS_MACOS) +#include +#include +#endif + // Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set static QByteArray buildSyntheticData(int numSets, int cardsPerSet) { @@ -137,9 +157,10 @@ TEST(OracleBenchmark, ParseJsonThroughput) for (int i = 0; i < iterations; ++i) { OracleImporter importer; + QByteArray source = data; QElapsedTimer timer; timer.start(); - bool ok = importer.readSetsFromByteArray(data); + bool ok = importer.readSetsFromByteArray(std::move(source)); ASSERT_TRUE(ok); totalMs += timer.elapsed(); } @@ -257,8 +278,301 @@ TEST(OracleBenchmark, ImportCardsWithColors) .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); } +// ============================================================================ +// RAM usage measurement +// ============================================================================ + +// Mirrors the default AllPrintings URL selection in oracle/src/pages.cpp. +#if defined(HAS_LZMA) +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.xz"); +#elif defined(HAS_ZLIB) +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.zip"); +#else +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json"); +#endif + +// Magic bytes also from oracle/src/pages.cpp +static const QByteArray kXzSignature("\xFD\x37\x7A\x58\x5A", 6); +static const QByteArray kZipSignature("PK"); + +struct MemorySnapshot +{ + qint64 peakRssKb = -1; // process high-water mark (VmHWM on Linux, ru_maxrss on macOS) + qint64 rssKb = -1; // current resident set size + bool available = false; + + static MemorySnapshot current() + { + MemorySnapshot snap; +#if defined(Q_OS_LINUX) + QFile statusFile("/proc/self/status"); + if (statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + // /proc files report size() == 0, so atEnd() is immediately true: read everything first. + const QList lines = statusFile.readAll().split('\n'); + for (const QByteArray &line : lines) { + if (line.startsWith("VmHWM:")) { + snap.peakRssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); + } else if (line.startsWith("VmRSS:")) { + snap.rssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); + } + } + snap.available = snap.peakRssKb >= 0; + } +#elif defined(Q_OS_MACOS) + struct rusage usage; + if (getrusage(RUSAGE_SELF, &usage) == 0) { + snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB + snap.available = snap.peakRssKb >= 0; + } + // getrusage has no current-RSS equivalent; task_info's resident_size + // is the closest macOS analog to Linux VmRSS. + mach_task_basic_info info = {}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count) == + KERN_SUCCESS) { + snap.rssKb = info.resident_size / 1024; + } +#endif + return snap; + } +}; + +static QString formatKb(qint64 kb) +{ + if (kb < 0) { + return "N/A"; + } + return QString("%1 MB").arg(kb / 1024.0, 0, 'f', 1); +} + +static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, const MemorySnapshot ¤t) +{ + if (!baseline.available || !current.available) { + qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase); + return; + } + // VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a + // peak-based delta between phases is ~0.0 MB by construction once the + // fixture build has set the process peak. The live signals are current RSS + // and the process peak; the delta is meaningful only where the baseline was + // taken immediately before the phase it measures (e.g. the import phase, + // which compares afterParse against afterImport). + QString rssDelta = "N/A"; + if (current.rssKb >= 0 && baseline.rssKb >= 0) { + rssDelta = formatKb(current.rssKb - baseline.rssKb); + } + qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4") + .arg(phase) + .arg(formatKb(current.rssKb)) + .arg(rssDelta) + .arg(formatKb(current.peakRssKb)); +} + +// Decompresses the download payload when the default URL is a compressed build, +// mirroring the wizard's magic-byte handling in oracle/src/pages.cpp. +static QByteArray decompressSetsData(const QByteArray &payload) +{ + if (payload.startsWith(kXzSignature)) { +#if defined(HAS_LZMA) + QBuffer inBuffer(const_cast(&payload)); + QByteArray out; + QBuffer outBuffer(&out); + inBuffer.open(QIODevice::ReadOnly); + outBuffer.open(QIODevice::WriteOnly); + XzDecompressor xz; + if (!xz.decompress(&inBuffer, &outBuffer)) { + qDebug() << "RAM benchmark: xz decompression failed"; + return {}; + } + return out; +#else + qDebug() << "RAM benchmark: download is xz-compressed but this build has no LZMA support"; + return {}; +#endif + } + if (payload.startsWith(kZipSignature)) { +#if defined(HAS_ZLIB) + QBuffer inBuffer(const_cast(&payload)); + inBuffer.open(QIODevice::ReadOnly); + UnZip unzip; + if (unzip.openArchive(&inBuffer) != UnZip::Ok) { + qDebug() << "RAM benchmark: zip archive open failed"; + return {}; + } + if (unzip.fileList().size() != 1) { + qDebug() << "RAM benchmark: zip archive doesn't contain exactly one file"; + return {}; + } + QByteArray out; + QBuffer outBuffer(&out); + outBuffer.open(QIODevice::WriteOnly); + const auto errorCode = unzip.extractFile(unzip.fileList().value(0), &outBuffer); + unzip.closeArchive(); + if (errorCode != UnZip::Ok) { + qDebug() << "RAM benchmark: zip extraction failed"; + return {}; + } + return out; +#else + qDebug() << "RAM benchmark: download is zip-compressed but this build has no zlib support"; + return {}; +#endif + } + return payload; +} + +TEST(OracleBenchmark, ImportRamUsage) +{ + static constexpr int numSets = 30; + static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale + + // Baseline must precede the fixture build: a high-water mark set while + // generating the synthetic JSON would otherwise mask the importer phases. + // Where memory stats are unavailable (Windows), skip before doing the + // 60k-card fixture build, which would otherwise be pure wasted work. + const MemorySnapshot baseline = MemorySnapshot::current(); + if (!baseline.available) { + GTEST_SKIP() << "Memory stats unavailable on this platform"; + } + + const QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + // The fixture build leaves freed-but-unreturned arenas behind (current RSS + // rarely falls once glibc allocates). Baseline immediately after it so the + // parse phase measures only the importer's own growth (~40 MB) rather than + // swallowing the fixture builder's spike. + const MemorySnapshot afterFixture = MemorySnapshot::current(); + logRamPhase("fixture build", baseline, afterFixture); + + NoopCardSetPriorityController controller; + OracleImporter importer; + + QElapsedTimer timer; + timer.start(); + ASSERT_TRUE(importer.readSetsFromByteArray(std::move(data))); + const qint64 parseMs = timer.elapsed(); + const MemorySnapshot afterParse = MemorySnapshot::current(); + + timer.restart(); + const int importedSets = importer.startImport(); + const qint64 importMs = timer.elapsed(); + const MemorySnapshot afterImport = MemorySnapshot::current(); + + importer.releaseSetData(); + const MemorySnapshot afterRelease = MemorySnapshot::current(); + + const int totalCards = importer.getCardList().size(); + qDebug().noquote() << QString("Oracle RAM Benchmark (synthetic): %1 sets, %2 cards, %3 MB JSON") + .arg(importedSets) + .arg(totalCards) + .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + logRamPhase("parse", afterFixture, afterParse); + logRamPhase("import", afterParse, afterImport); + logRamPhase("after releaseSetData()", afterImport, afterRelease); + + // Freeing the parsed tree rarely moves current RSS (allocator reuse), so the + // meaningful signal that release actually dropped the buffers is emptiness, + // not an RSS delta. + ASSERT_TRUE(importer.getSets().isEmpty()); +} + +TEST(OracleBenchmark, ImportRamUsageAllPrintings) +{ + // Only "1" enables the download: unset (the default and the CI setup) and + // an explicit "0" both disable it. + bool envOk = false; + const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk); + if (!envOk || enabled == 0) { + GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this " + "RAM benchmark. Default URL: " + << kDefaultAllPrintingsUrl.toDisplayString().toStdString(); + } + + // Baseline must precede the request so the phase covers the download + + // decompress step, including the payload materialized by readAll(). + const MemorySnapshot baseline = MemorySnapshot::current(); + if (!baseline.available) { + GTEST_SKIP() << "Memory stats unavailable on this platform"; + } + + QNetworkAccessManager nam; + QNetworkRequest request(kDefaultAllPrintingsUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark"); + QNetworkReply *reply = nam.get(request); + + QEventLoop loop; + QTimer timeoutTimer; + timeoutTimer.setSingleShot(true); + bool timedOut = false; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] { + timedOut = true; + reply->abort(); + }); + timeoutTimer.start(10 * 60 * 1000); + loop.exec(); + timeoutTimer.stop(); + + // abort() leaves reply->error() as OperationCanceledError, so a timed-out + // download takes the same GTEST_SKIP path as any other network error + // instead of reading a truncated body and failing the parse below. + if (timedOut || reply->error() != QNetworkReply::NoError) { + GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString(); + } + const QByteArray payload = reply->readAll(); + reply->deleteLater(); + + // mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check + // in pages.cpp); reject it before trying to decompress/parse. + if (payload.startsWith("<")) { + GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping"; + } + + const QByteArray setsData = decompressSetsData(payload); + const MemorySnapshot afterDownload = MemorySnapshot::current(); + if (setsData.isEmpty()) { + GTEST_SKIP() << "No data to import (download or decompression failed)"; + } + + NoopCardSetPriorityController controller; + OracleImporter importer; + + QElapsedTimer timer; + timer.start(); + ASSERT_TRUE(importer.readSetsFromByteArray(std::move(setsData))); + const qint64 parseMs = timer.elapsed(); + const MemorySnapshot afterParse = MemorySnapshot::current(); + + timer.restart(); + const int importedSets = importer.startImport(); + const qint64 importMs = timer.elapsed(); + const MemorySnapshot afterImport = MemorySnapshot::current(); + + importer.releaseSetData(); + const MemorySnapshot afterRelease = MemorySnapshot::current(); + + const int totalCards = importer.getCardList().size(); + qDebug().noquote() << QString("Oracle RAM Benchmark (real AllPrintings): %1 sets, %2 unique cards") + .arg(importedSets) + .arg(totalCards); + qDebug().noquote() << QString(" URL: %1").arg(kDefaultAllPrintingsUrl.toDisplayString()); + qDebug().noquote() << QString(" Downloaded: %1 MB, decompressed: %2 MB") + .arg(payload.size() / (1024.0 * 1024.0), 0, 'f', 1) + .arg(setsData.size() / (1024.0 * 1024.0), 0, 'f', 1); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + logRamPhase("download+decompress", baseline, afterDownload); + logRamPhase("parse", afterDownload, afterParse); + logRamPhase("import", afterParse, afterImport); + logRamPhase("after releaseSetData()", afterImport, afterRelease); +} + int main(int argc, char **argv) { + // Required for the event loop used by the real-AllPrintings download benchmark + QCoreApplication app(argc, argv); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 145a2ca0f..3834be9ed 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -545,6 +545,202 @@ TEST_F(OracleImporterTest, ApostropheNormalized) ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity")); } +// ============================================================================ +// RawJson scanner tests +// ============================================================================ + +TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse) +{ + QJsonObject root; + QJsonObject data; + data["AAA"] = makeCard("Alpha Card"); + data["BBB"] = makeCard("Beta Card"); + root["data"] = data; + + const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact); + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(bytes, &error); + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 2); + + const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject(); + for (const RawJson::SetRange &range : ranges) { + QJsonParseError parseError; + const QJsonDocument sliceDoc = QJsonDocument::fromJson( + QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError); + ASSERT_EQ(parseError.error, QJsonParseError::NoError) + << range.code.toStdString() << ": " << parseError.errorString().toStdString(); + ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString(); + } +} + +TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards) +{ + const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\"," + "\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\"," + "\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}"; + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(json, &error); + ASSERT_FALSE(error.isError()); + ASSERT_EQ(ranges.size(), 1); + + const RawJson::SetRange &range = ranges.first(); + ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key + const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00); + ASSERT_EQ(range.name, expectedName); + ASSERT_EQ(range.type, "expansion"); + ASSERT_EQ(range.releaseDate, "2024-01-05"); + ASSERT_EQ(range.dataRange.cardCount, 3); + + QJsonParseError parseError; + const QJsonDocument sliceDoc = QJsonDocument::fromJson( + QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError); + ASSERT_EQ(parseError.error, QJsonParseError::NoError); + ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName); + ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3); +} + +TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson) +{ + const QList invalid = {"not json", + "[]", + "{\"data\":[]}", + "{\"data\":{}}", + "{\"other\":{}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\"," + "\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"}; + + for (const QByteArray &json : invalid) { + RawJson::ScanError error; + RawJson::scanSetRanges(json, &error); + EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData(); + } +} + +TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts) +{ + // Verdicts must agree with QJsonDocument::fromJson for the inputs below — + // including the metadata quirks ("name": null, "type": 7, "releaseDate": null, + // "cards": null) that used to make the scanner reject sets Qt accepts. + const QList inputs = { + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{" + "\"n\":1}]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}", + // unescaped control character inside a string: QJsonDocument and + // skipString both accept it, so the scanner must not reject the whole doc + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}", + // structurally invalid JSON (both parsers must reject) + "not json", + "{\"data\":{\"A\":{\"name\":\"unterminated}}", + }; + + for (const QByteArray &input : inputs) { + QJsonParseError qtError; + QJsonDocument::fromJson(input, &qtError); + const bool qtOk = qtError.error == QJsonParseError::NoError; + + RawJson::ScanError scanError; + const QList ranges = RawJson::scanSetRanges(input, &scanError); + EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData(); + if (scanError.isError()) { + continue; + } + for (const RawJson::SetRange &range : ranges) { + QJsonParseError sliceError; + QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length), + &sliceError); + EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData(); + } + } +} + +TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting) +{ + // Far beyond the shared 1024 container cap: Qt reports DeepNesting and the + // scanner must reject too, without overflowing the stack through its + // recursive skipValue walk. + QString nesting; + nesting.reserve(10000); + for (int i = 0; i < 5000; ++i) { + nesting += '['; + } + for (int i = 0; i < 5000; ++i) { + nesting += ']'; + } + const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); + + QJsonParseError qtError; + QJsonDocument::fromJson(json, &qtError); + ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting"; + + RawJson::ScanError scanError; + RawJson::scanSetRanges(json, &scanError); + ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested"; +} + +TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting) +{ + // Pins the boundary rather than only the far-past case: a depth Qt still + // accepts must be accepted by the scanner too. Before the fix the scanner's + // cap was roughly half of Qt's (each level cost two decrements), so a + // depth of 1000 here was rejected even though QJsonDocument parses it. + constexpr int depth = 1000; + QString nesting; + nesting.reserve(2 * depth); + for (int i = 0; i < depth; ++i) { + nesting += '['; + } + for (int i = 0; i < depth; ++i) { + nesting += ']'; + } + const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); + + QJsonParseError qtError; + QJsonDocument::fromJson(json, &qtError); + ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth; + + RawJson::ScanError scanError; + RawJson::scanSetRanges(json, &scanError); + ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth; +} + +// ============================================================================ +// Lazy per-set parsing tests +// ============================================================================ + +TEST_F(OracleImporterTest, StartImportParsesSetsLazily) +{ + QJsonObject setObj = makeCard("Lazy Import Card"); + QJsonArray cards; + cards.append(setObj); + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_FALSE(importer->getRawSetsData().isEmpty()); + + const int importedSets = importer->startImport(); + ASSERT_EQ(importedSets, 1); + ASSERT_EQ(importer->getCardList().size(), 1); + ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull()); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv);