diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index f37315262..b08e568f3 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -8,6 +8,7 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ gtest \ mariadb-libs \ ninja \ + openssl \ protobuf \ qt6-base \ qt6-declarative \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 0fa227d6f..e3df94ab5 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -15,6 +15,7 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 13e8b35c7..60e490c98 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 68e894543..4005bbf67 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -7,6 +7,7 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index ffd7c1b9b..e0224cdc6 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -7,6 +7,7 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index 21f6a036e..321aa7c0f 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update && \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-tools-dev \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 12320c276..10adc5e64 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -15,6 +15,7 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index ce3d9cd6c..1b6cf825f 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ 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 bac46c2bc..0da073464 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,11 +5,11 @@ # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# cmake 3.16 is required if using qt6 -cmake_minimum_required(VERSION 3.10) +# 3.16 required for Qt6 and target_precompile_headers() +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) @@ -184,6 +195,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}") endif() endforeach() + + # Reduce compiler I/O by using pipes between stages instead of temp files + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") else() # other: osx/llvm, bsd/llvm set(CMAKE_CXX_FLAGS_RELEASE "-O2") @@ -192,6 +206,9 @@ else() else() set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra") endif() + + # Reduce compiler I/O by using pipes between stages instead of temp files + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") endif() # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning @@ -239,11 +256,6 @@ if(WIN32) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) - else() - message( - WARNING - "Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime." - ) endif() endif() diff --git a/Dockerfile b/Dockerfile index 382309d47..7d3deb5fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ + libssl-dev \ qt6-websockets-dev \ protobuf-compiler \ qt6-tools-dev \ @@ -42,6 +43,7 @@ RUN apt-get update \ libprotobuf32t64 \ libqt6sql6-mysql \ libqt6websockets6 \ + libssl3 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* 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/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 87af4c73c..457f1b3f7 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -44,11 +44,16 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { - // Sever all incoming connections (animated item destroy-tracking) before the - // members below are destroyed: the base QGraphicsScene destructor destroys the - // remaining items, and their destroyed() signals must not reach slots that - // reference members that no longer exist. - QObject::disconnect(nullptr, nullptr, this, nullptr); + // Sever all destroyed->removeAnimatedItem connections before the members below + // are destroyed: the base QGraphicsScene destructor destroys the remaining items, + // and their destroyed() signals must not reach slots that reference members that + // no longer exist. The connection handle overload is used because the string-based + // disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be + // nullptr) and would otherwise fail to sever these pointer-to-member connections. + for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) { + QObject::disconnect(*it); + } + animationItemConnections.clear(); delete animationTimer; animationTimer = nullptr; @@ -777,8 +782,15 @@ void GameScene::registerAnimationItem(IAnimatedItem *item) if (!object) { return; } - if (!animatedItems.contains(object)) { - connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem); + // Guard against duplicate connections using the connection map, not + // animatedItems: the animation timer removes entries from animatedItems when an + // animation completes, but the destroyed->removeAnimatedItem connection must + // persist until the object is destroyed. Relying on animatedItems here would let + // a re-registered item (e.g. a life counter that flashes repeatedly) accumulate + // duplicate destroyed connections, the older ones of which would survive teardown. + if (!animationItemConnections.contains(object)) { + animationItemConnections.insert(object, + connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem)); } animatedItems.insert(object, item); if (animationTimer && !animationTimer->isActive()) { @@ -797,6 +809,7 @@ void GameScene::unregisterAnimationItem(IAnimatedItem *item) void GameScene::removeAnimatedItem(QObject *item) { animatedItems.remove(item); + animationItemConnections.remove(item); if (animationTimer && animatedItems.isEmpty()) { animationTimer->stop(); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index c12696189..859d7a6eb 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -54,9 +54,11 @@ private: QPointer hoveredCard; ///< Currently hovered card QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating - int playerRotation; ///< Rotation offset for player layout - bool rearranging = false; ///< Guard against re-entrant rearrange - bool needsReArrange = false; ///< Pending rearrange requested during a pass + QHash + animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item + int playerRotation; ///< Rotation offset for player layout + bool rearranging = false; ///< Guard against re-entrant rearrange + bool needsReArrange = false; ///< Pending rearrange requested during a pass /** * @brief Updates which card is currently hovered based on scene coordinates. 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/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index b52a4955a..1a8f7a910 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -41,7 +41,8 @@ void HandZone::handleDropEvent(const QList &dragItems, } } } else { - x = calcDropIndexFromY(dropPoint.y()); + bool sameZone = startZone == getLogic(); + x = calcDropIndexFromY(dropPoint.y(), !sameZone); } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index c58c41b92..470c70fcf 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons return {cardCount, boundingRect().height(), cardHeight, offset, minOffset}; } -int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const +int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const { const auto &cards = getLogic()->getCards(); if (cards.isEmpty()) { @@ -94,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const if (effectiveOffset <= 0.0) { return 0; } - return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1); + int max = allowCountExpand ? params.cardCount : params.cardCount - 1; + return qBound(0, qRound((dropY - start) / effectiveOffset), max); } void SelectZone::restoreStaleEscapedCards() diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index 7408f29b6..b5d3ca37a 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -104,8 +104,12 @@ protected: /** * @brief Computes the card index at a given y-coordinate within the zone's vertical layout. * Returns 0 if the zone has no cards or the offset is zero. + * + * @param dropY The y-coordinate that the card was dropped at + * @param allowCountExpand If false, clamps the index at the number of cards minus 1 + * @param minOffset Minimum offset to preserve */ - int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const; + int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const; /** * @brief Positions cards vertically with alternating left/right x-offsets. diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index e9b14f13d..ff62097c7 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -57,18 +57,14 @@ void StackZone::handleDropEvent(const QList &dragItems, return; } - const auto &cards = getLogic()->getCards(); - int index; - if (startZone == getLogic()) { - // Reordering within the zone: use drop position - index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE); + bool sameZone = startZone == getLogic(); + int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); + if (sameZone) { // Same-zone no-op: don't move a card onto itself + const auto &cards = getLogic()->getCards(); if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { return; } - } else { - // Coming from another zone: append at end (top of stack, rendered on top) - index = static_cast(cards.size()); } Command_MoveCard cmd; 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/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index b0dac3e7c..ed0ddaf06 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -116,9 +116,10 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) } TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) - : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr), - tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), - tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false) + : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), + tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), + tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), + tabModeration(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -245,6 +246,7 @@ void TabSupervisor::retranslateUi() aTabLog->setText(tr("Logs")); aTabReport->setText(tr("Report Queue")); aTabModeration->setText(tr("Moderation")); + aTabCardArtRules->setText(tr("Card Art Rules")); // tabs QList tabs; @@ -256,6 +258,7 @@ void TabSupervisor::retranslateUi() tabs.append(tabLog); tabs.append(tabReport); tabs.append(tabModeration); + tabs.append(tabCardArtRules); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -520,7 +523,9 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabModerationOpen()) { openTabModeration(); } - openTabCardArtRules(); + if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) { + openTabCardArtRules(); + } } retranslateUi(); @@ -582,6 +587,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabCardArtRules) { + tabCardArtRules->close(); + } } QList tabsToDelete; @@ -775,6 +783,7 @@ void TabSupervisor::openTabAdmin() void TabSupervisor::actTabCardArtRules(bool checked) { + SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked); if (checked && !tabCardArtRules) { openTabCardArtRules(); setCurrentWidget(tabCardArtRules); 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/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 84d5d175f..d8aa1cd08 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -53,6 +53,7 @@ #include #include #include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -292,7 +293,7 @@ int main(int argc, char *argv[]) } } - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; diff --git a/format.sh b/format.sh index 3fa435be1..9e3a6069b 100755 --- a/format.sh +++ b/format.sh @@ -18,11 +18,11 @@ include=("cockatrice/src" \ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ +"cmake/pch" \ "tests") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ -"oracle/src/qt-json/" \ "oracle/src/zip/" \ "servatrice/src/smtp/") exts=("cpp" "h" "proto") 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_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h index a81616cb0..054c4cd72 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -21,6 +21,7 @@ public: [[nodiscard]] virtual bool getTabLogOpen() const = 0; [[nodiscard]] virtual bool getTabReportOpen() const = 0; [[nodiscard]] virtual bool getTabModerationOpen() const = 0; + [[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0; }; #endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H 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/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 3a193ae3c..fd254a564 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -60,6 +60,7 @@ set(PROTO_FILES command_set_sideboard_lock.proto command_set_sideboard_plan.proto command_shuffle.proto + command_tournament.proto command_undo_draw.proto commands.proto context_concede.proto @@ -90,7 +91,6 @@ set(PROTO_FILES event_game_log_notice.proto event_game_say.proto event_game_state_changed.proto - event_game_state_changed.proto event_join.proto event_join_room.proto event_kicked.proto @@ -118,6 +118,7 @@ set(PROTO_FILES event_set_card_counter.proto event_set_counter.proto event_shuffle.proto + event_tournament_state.proto event_user_joined.proto event_user_left.proto event_user_message.proto @@ -184,6 +185,7 @@ set(PROTO_FILES serverinfo_replay_match.proto serverinfo_report.proto serverinfo_room.proto + serverinfo_tournament.proto serverinfo_user.proto serverinfo_user_alt.proto serverinfo_user_session.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto new file mode 100644 index 000000000..f465ed6f7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_tournament.proto @@ -0,0 +1,31 @@ +syntax = "proto2"; +import "game_commands.proto"; +import "serverinfo_tournament.proto"; + +message Command_ReportMatchResult { + extend GameCommand { + optional Command_ReportMatchResult ext = 1037; + } + + optional sint32 game_id = 1 [default = -1]; + // The winning player's ID. A drawn match is reported with is_draw set and + // winner_id left unset so a missing report stays distinguishable from a + // reported draw via has_winner_id(). + optional sint32 winner_id = 2 [default = -1]; + // Whether the match ended in a draw; winner_id is meaningless then. + optional bool is_draw = 3; +} + +message Command_AdvanceTournament { + extend GameCommand { + optional Command_AdvanceTournament ext = 1038; + } +} + +message Command_TournamentSettingsSelect { + extend GameCommand { + optional Command_TournamentSettingsSelect ext = 1039; + } + + optional TournamentSettings settings = 1; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto index 5c8aee3fe..ae14cb44b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto @@ -24,4 +24,7 @@ message Event_GameStateChanged { // the amount of seconds since the game started optional uint32 seconds_elapsed = 5; + + // for tournament sub-games: the ID of the parent tournament game (-1 if not a sub-game) + optional sint32 parent_game_id = 10 [default = -1]; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto new file mode 100644 index 000000000..cfaaa6f01 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_tournament_state.proto @@ -0,0 +1,22 @@ +syntax = "proto2"; +import "game_event.proto"; +import "serverinfo_tournament.proto"; + +message Event_TournamentState { + extend GameEvent { + optional Event_TournamentState ext = 2027; + } + + enum TournamentPhase { + PHASE_DECK_BUILDING = 0; + PHASE_PLAYING = 1; + PHASE_FINISHED = 2; + } + + optional TournamentPhase phase = 1; + optional uint32 current_round = 2; + optional uint32 total_rounds = 3; + repeated TournamentPlayer players = 4; + repeated TournamentPairing pairings = 5; + optional TournamentSettings settings = 6; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 2e5b88978..68a79eb6b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -180,6 +180,15 @@ message GameCommand { /// Server: Server_Player::cmdSetPlaymat /// Client: reflected via player properties changed event SET_PLAYMAT = 1035; + + /// Report the result of a tournament match sub-game. + REPORT_MATCH_RESULT = 1037; + + /// Advance the tournament to the next round. + ADVANCE_TOURNAMENT = 1038; + + /// Select tournament settings. + TOURNAMENT_SETTINGS_SELECT = 1039; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto index 7d3147701..b952bfdef 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto @@ -34,6 +34,7 @@ message GameEvent { CHANGE_ZONE_PROPERTIES = 2020; REVERSE_TURN = 2021; GAME_LOG_NOTICE = 2022; + TOURNAMENT_STATE = 2027; } optional sint32 player_id = 1 [default = -1]; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index a8c90ec6c..e2f46e338 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -1,4 +1,5 @@ syntax = "proto2"; +import "serverinfo_tournament.proto"; message RoomCommand { enum RoomCommandType { LEAVE_ROOM = 1000; @@ -69,6 +70,12 @@ message Command_CreateGame { // share decklists with all players when selected optional bool share_decklists_on_load = 14; + + // tournament settings shared by game creation and mid-game changes + optional TournamentSettings tournament_settings = 15; + + // whether this is a tournament game + optional bool is_tournament = 16; } message Command_JoinGame { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9989ae18a..4ac004cf4 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -65,4 +65,7 @@ message ServerInfo_Game { // the current host of the game, which may differ from the creator after a host transfer optional ServerInfo_User host_info = 53; + + // whether this game is a tournament game + optional bool is_tournament = 54; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto new file mode 100644 index 000000000..217967ff8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_tournament.proto @@ -0,0 +1,32 @@ +syntax = "proto2"; + +// Player-entered tournament metadata as shown in the standings and bracket views. +message TournamentPlayer { + optional sint32 player_id = 1; + optional string player_name = 2; + optional uint32 wins = 3; + optional uint32 losses = 4; + optional uint32 draws = 5; + optional bool deck_submitted = 6; +} + +// A single match pairing within a tournament round. +message TournamentPairing { + optional sint32 player1_id = 1; + // -1 when the pairing is a bye. + optional sint32 player2_id = 2 [default = -1]; + optional sint32 game_id = 3; + // The winning player's ID, or -1 when the match ended in a draw (is_draw). + // An unset field reads back as -1 too; has_winner_id() distinguishes those. + optional sint32 winner_id = 4 [default = -1]; + optional uint32 player1_match_wins = 5; + optional uint32 player2_match_wins = 6; + // Whether the match ended in a draw; winner_id is meaningless then. + optional bool is_draw = 7; +} + +// Settings governing all matches of a tournament. +message TournamentSettings { + // Number of games per match (e.g. 3 for best of 3). + optional uint32 games_per_match = 1 [default = 1]; +} \ No newline at end of file diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp index 5b38deb3f..4c578b4e4 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,6 +1,5 @@ #include "rng_sfmt.h" -#include #include #include #include @@ -11,10 +10,11 @@ #define UINT64_MAX (~(uint64_t)0) #endif -RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) +RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent) { - // initialize the random number generator with a 32bit integer seed (timestamp) - sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); + // initialize the random number generator with a 64bit seed, e.g. from a CSPRNG + uint32_t seedArray[2] = {static_cast(seed), static_cast(seed >> 32)}; + sfmt_init_by_array(&sfmt, seedArray, 2); } /** diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h index 7e9f53df3..a180dad99 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h @@ -36,7 +36,7 @@ private: unsigned int cdf(unsigned int min, unsigned int max); public: - explicit RNG_SFMT(QObject *parent = nullptr); + explicit RNG_SFMT(uint64_t seed, QObject *parent = nullptr); unsigned int rand(int min, int max) override; }; diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp index 85a1424a6..cf5bfd81a 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -106,6 +106,11 @@ bool TabsSettings::getTabModerationOpen() const return getValue("moderation", QString(), QString(), false).toBool(); } +bool TabsSettings::getTabCardArtRulesOpen() const +{ + return getValue("cardArtRules", QString(), QString(), false).toBool(); +} + void TabsSettings::setTabVisualDeckStorageOpen(bool value) { setValue(value, "visualDeckStorage"); @@ -150,3 +155,8 @@ void TabsSettings::setTabModerationOpen(bool value) { setValue(value, "moderation"); } + +void TabsSettings::setTabCardArtRulesOpen(bool value) +{ + setValue(value, "cardArtRules"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h index 365d91af7..eb78d311b 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -43,6 +43,7 @@ public: [[nodiscard]] bool getTabLogOpen() const override; [[nodiscard]] bool getTabReportOpen() const override; [[nodiscard]] bool getTabModerationOpen() const override; + [[nodiscard]] bool getTabCardArtRulesOpen() const override; void setStartupTabIndex(int value); void setStartupServerHost(const QString &host); @@ -57,6 +58,7 @@ public: void setTabLogOpen(bool value); void setTabReportOpen(bool value); void setTabModerationOpen(bool value); + void setTabCardArtRulesOpen(bool value); signals: void startupTabIndexChanged(int index); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index c6411ea76..db23f7951 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -6,13 +6,15 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(UTILITY_SOURCES - libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp libcockatrice/utility/passwordhasher.cpp - libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/warning_categories.cpp + libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp + libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/warning_categories.cpp ) set(UTILITY_HEADERS libcockatrice/utility/card_ref.h libcockatrice/utility/color.h + libcockatrice/utility/cryptoutil.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h libcockatrice/utility/macros.h @@ -32,7 +34,9 @@ add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) +find_package(OpenSSL REQUIRED) + +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE}) set(ORACLE_LIBS) diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp new file mode 100644 index 000000000..416ef261b --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -0,0 +1,25 @@ +#include "cryptoutil.h" + +#include + +namespace CryptoUtil +{ +QByteArray randomBytes(int count) +{ + QByteArray bytes(count, '\0'); + if (RAND_bytes(reinterpret_cast(bytes.data()), count) != 1) { + // Randomness failure is fatal: never fall back to a predictable source. + qFatal("CryptoUtil::randomBytes: RAND_bytes failed"); + } + return bytes; +} + +quint64 randomUInt64() +{ + quint64 value; + if (RAND_bytes(reinterpret_cast(&value), sizeof(value)) != 1) { + qFatal("CryptoUtil::randomUInt64: RAND_bytes failed"); + } + return value; +} +} // namespace CryptoUtil diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h new file mode 100644 index 000000000..dba9dc37d --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -0,0 +1,13 @@ +#ifndef CRYPTOUTIL_H +#define CRYPTOUTIL_H + +#include +#include + +namespace CryptoUtil +{ +QByteArray randomBytes(int count); +quint64 randomUInt64(); +} // namespace CryptoUtil + +#endif diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index c40c5f94f..1c22fdcfa 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -1,7 +1,7 @@ #include "passwordhasher.h" #include -#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -21,12 +21,28 @@ QString PasswordHasher::generateRandomSalt(const int len) static const char alphanum[] = "0123456789" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz"; + const int size = sizeof(alphanum) - 1; + + // Two bytes per character, corrected for modulo bias via rejection sampling. + const int bucketSize = 65536 / size; + const int limit = bucketSize * size; QString ret; - int size = sizeof(alphanum) - 1; - + ret.reserve(len); + QByteArray random = CryptoUtil::randomBytes(len * 2); + int bytesUsed = 0; for (int i = 0; i < len; ++i) { - ret.append(alphanum[rng->rand(0, size)]); + unsigned int value; + do { + if (bytesUsed >= random.size()) { + random = CryptoUtil::randomBytes(len * 2); + bytesUsed = 0; + } + value = static_cast(static_cast(random.at(bytesUsed))) << 8 | + static_cast(static_cast(random.at(bytesUsed + 1))); + bytesUsed += 2; + } while (value >= limit); + ret.append(alphanum[value / bucketSize]); } return ret; @@ -34,5 +50,5 @@ QString PasswordHasher::generateRandomSalt(const int len) QString PasswordHasher::generateActivationToken() { - return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); + return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); } diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0736db7f5..68c4709bb 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,7 +23,7 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp - src/qt-json/json.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 @@ -113,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 fdb32bb8d..d745b250c 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,10 +3,13 @@ #include "libcockatrice/interfaces/noop_card_preference_provider.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" -#include "qt-json/json.h" #include +#include +#include +#include #include +#include #include #include #include @@ -42,31 +45,29 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(const QByteArray &data) +bool OracleImporter::readSetsFromByteArray(QByteArray data) { - bool ok; - auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); - if (!ok) { - qDebug() << "error: QtJson::Json::parse()"; + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error); + if (error.isError()) { + qDebug() << "error: RawJson::scanSetRanges():" << error.message; return false; } QList newSetList; + newSetList.reserve(ranges.size()); - QListIterator it(setsMap.values()); - - while (it.hasNext()) { - QVariantMap map = it.next().toMap(); - QString shortName = map.value("code").toString().toUpper(); - QString longName = map.value("name").toString(); - QList setCards = map.value("cards").toList(); - QString setType = map.value("type").toString(); - QDate releaseDate = map.value("releaseDate").toDate(); + 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) { // basic grammar for words that aren't capitalized, like in "From the Vault" - const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; + static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", + "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -74,13 +75,15 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); + setType += item[0].toUpper() + item.mid(1) + QString(" "); first = true; } } 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()); @@ -89,6 +92,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) return false; } allSets = newSetList; + rawSetsData = std::move(data); return true; } @@ -122,14 +126,8 @@ static void sortAndReduceColors(QString &colors) std::sort(colors.begin(), colors.end(), [](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); }); // reduce - QChar lastChar = '\0'; - for (int i = 0; i < colors.size(); ++i) { - if (colors.at(i) == lastChar) { - colors.remove(i, 1); - } else { - lastChar = colors.at(i); - } - } + auto last = std::unique(colors.begin(), colors.end()); + colors.erase(last, colors.end()); } CardInfoPtr OracleImporter::addCard(QString name, @@ -142,9 +140,12 @@ CardInfoPtr OracleImporter::addCard(QString name, // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - if (cards.contains(name)) { - CardInfoPtr card = cards.value(name); + auto existingIt = cards.constFind(name); + if (existingIt != cards.constEnd()) { + CardInfoPtr card = existingIt.value(); card->addToSet(printingInfo.getSet(), printingInfo); + // Only merge legalities when the card has none yet, so multi-format + // printings don't overwrite each other's legality lists. if (card->getProperties().filter(formatRegex).empty()) { card->combineLegalities(properties); } @@ -182,8 +183,9 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || - properties.value("layout") == "planar"; + QString layoutVal = properties.value("layout"); + bool landscapeOrientation = + properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; @@ -222,12 +224,15 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) +static QString getJsonString(const QJsonObject &obj, const QString &key) { - return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); + // QVariant coerces numbers and booleans to text, while QJsonValue::toString() + // returns a null string for them — some MTGJSON fields (manaValue, + // convertedManaCost, isOnlineOnly, isRebalanced) carry those types. + return obj.value(key).toVariant().toString(); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -248,7 +253,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -256,16 +261,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QMap, QString>> splitCards; // Keeps track of all names encountered so far - QList allNameProps; + QSet allNameProps; - for (const QVariant &cardVar : cardsList) { - QVariantMap card = cardVar.toMap(); + for (const QJsonValue &cardVal : cardsList) { + QJsonObject card = cardVal.toObject(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getStringPropertyFromMap(card, "layout"); + QString layout = getJsonString(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -273,9 +278,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } // normal cards handling - QString name = getStringPropertyFromMap(card, "name"); - QString text = getStringPropertyFromMap(card, "text"); - QString faceName = getStringPropertyFromMap(card, "faceName"); + QString name = getJsonString(card, "name"); + QString text = getJsonString(card, "text"); + QString faceName = getJsonString(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } @@ -283,39 +288,34 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // card properties QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - properties.insert(xmlPropertyName, propertyValue); + properties.insert(i.value(), propertyValue); } } // per-set properties QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - printingProps.insert(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); + QString faceFlavorName = getJsonString(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName"); if (!flavorName.isEmpty()) { printingProps.insert("flavorName", flavorName); } // Identifiers + QJsonObject identifiers = card.value("identifiers").toObject(); for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); + QString propertyValue = getJsonString(identifiers, i.key()); if (!propertyValue.isEmpty()) { - printingProps.insert(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } @@ -331,21 +331,26 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.append(faceName); + allNameProps.insert(faceName); // special handling properties - QString colors = card.value("colors").toStringList().join(""); + QString colors; + for (const QJsonValue &color : card.value("colors").toArray()) { + colors += color.toString(); + } if (!colors.isEmpty()) { properties.insert("colors", colors); } - // special handling properties - QString colorIdentity = card.value("colorIdentity").toStringList().join(""); + QString colorIdentity; + for (const QJsonValue &color : card.value("colorIdentity").toArray()) { + colorIdentity += color.toString(); + } if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -354,22 +359,22 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // Depending on whether power and/or toughness are present, the format // is either P/T (most common), P (no toughness), or /T (no power). - QString power = getStringPropertyFromMap(card, "power"); - QString toughness = getStringPropertyFromMap(card, "toughness"); + QString power = getJsonString(card, "power"); + QString toughness = getJsonString(card, "toughness"); if (toughness.isEmpty() && !power.isEmpty()) { properties.insert("pt", power); } else if (!toughness.isEmpty()) { properties.insert("pt", power + ptSeparator + toughness); } - auto legalities = card.value("legalities").toMap(); - for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { + auto legalities = card.value("legalities").toObject(); + for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) { properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower()); } // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { - auto _faceName = getStringPropertyFromMap(card, "faceName"); + auto _faceName = getJsonString(card, "faceName"); SplitCardPart split(_faceName, text, properties, printingInfo); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { @@ -382,11 +387,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QList relatedCards; // add other face for split cards as card relation - if (!getStringPropertyFromMap(card, "side").isEmpty()) { - auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); + if (!getJsonString(card, "side").isEmpty()) { + auto faceManaValue = getJsonString(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); + faceManaValue = getJsonString(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -406,20 +411,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList name = faceName; } - // mtgjon related cards - if (card.contains("relatedCards")) { - QVariantMap givenRelated = card.value("relatedCards").toMap(); + // mtgjson related cards + QJsonObject givenRelated = card.value("relatedCards").toObject(); + if (!givenRelated.isEmpty()) { // conjured cards from a spellbook - if (givenRelated.contains("spellbook")) { - auto spbk = givenRelated.value("spellbook").toStringList(); - for (const QString &spbkName : spbk) { - relatedCards.append( - new CardRelation(spbkName, CardRelationType::DoesNotAttach, false, false, 1, true)); + QJsonArray spellbook = givenRelated.value("spellbook").toArray(); + if (!spellbook.isEmpty()) { + for (const QJsonValue &spbkVal : spellbook) { + relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false, + false, 1, true)); } } } - CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); + CardInfoPtr newCard = + addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; } } @@ -427,7 +433,6 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // split cards handling static const QString splitCardPropSeparator = QString(" // "); static const QString splitCardTextSeparator = QString("\n\n---\n\n"); - static const QList noRelatedCards = {}; QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { @@ -453,7 +458,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) { if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty properties.insert(prop, thisCardPropertyValue); - } else if (prop == "colors") { // the card is both colors + } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); } else if (prop == "maintype") { // don't create maintypes with //es in them continue; @@ -465,20 +470,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } } } - CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); numCards++; } return numCards; } -FormatRulesNameMap OracleImporter::createDefaultMagicFormats() +static FormatRulesNameMap buildDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; + superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -491,7 +496,6 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); - // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -537,10 +541,27 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() return defaultFormatRulesNameMap; } +const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats() +{ + static const FormatRulesNameMap cached = buildDefaultMagicFormats(); + return cached; +} + int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); + // 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 = CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens"); @@ -552,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; @@ -576,9 +630,16 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); } +void OracleImporter::releaseSetData() +{ + allSets.clear(); + rawSetsData.clear(); +} + void OracleImporter::clear() { sets.clear(); cards.clear(); allSets.clear(); + rawSetsData.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 99644f9ce..8cb30ca40 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,6 +1,11 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H +#include "raw_json_scanner.h" + +#include +#include +#include #include #include #include @@ -44,10 +49,12 @@ class SetToDownload { private: QString shortName, longName; - QList 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 @@ -58,10 +65,6 @@ public: { return longName; } - const QList &getCards() const - { - return cards; - } const QString &getSetType() const { return setType; @@ -74,16 +77,23 @@ public: { return priority; } + const RawJson::SetDataRange &getRawRange() const + { + return rawRange; + } SetToDownload(QString _shortName, QString _longName, - QList _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; @@ -139,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, @@ -151,11 +167,18 @@ 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 QList &cardsList); - FormatRulesNameMap createDefaultMagicFormats(); + int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); + /** + * @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable. + */ + const FormatRulesNameMap &createDefaultMagicFormats(); const CardNameMap &getCardList() const { return cards; @@ -164,6 +187,11 @@ public: { return allSets; } + const QByteArray &getRawSetsData() const + { + return rawSetsData; + } + void releaseSetData(); void clear(); }; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index df4d1a98c..94e662ffe 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -560,6 +560,9 @@ void SaveSetsPage::initializePage() int setsImported = wizard()->importer->startImport(); + // JSON data no longer needed after CardInfo objects are built + wizard()->importer->releaseSetData(); + if (setsImported == 0) { QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); } diff --git a/oracle/src/qt-json/AUTHORS b/oracle/src/qt-json/AUTHORS deleted file mode 100644 index 29a85929f..000000000 --- a/oracle/src/qt-json/AUTHORS +++ /dev/null @@ -1,3 +0,0 @@ -Eeli Reilin -Luis Gustavo S. Barreto -Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE deleted file mode 100644 index 3c42b515a..000000000 --- a/oracle/src/qt-json/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright 2011 Eeli Reilin. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO -EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE -OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF -ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation -are those of the authors and should not be interpreted as representing -official policies, either expressed or implied, of Eeli Reilin. - diff --git a/oracle/src/qt-json/README b/oracle/src/qt-json/README deleted file mode 100644 index b60c1599b..000000000 --- a/oracle/src/qt-json/README +++ /dev/null @@ -1,96 +0,0 @@ -######################################################################## -1. INTRODUCTION - -The Json class is a simple class for parsing JSON data into a QVariant -hierarchies. Now, we can also reverse the process and serialize -QVariant hierarchies into valid JSON data. - - -######################################################################## -2. HOW TO USE - -The parser is really easy to use. Let's say we have the following -QString of JSON data: - ------------------------------------------------------------------------- -{ - "encoding" : "UTF-8", - "plug-ins" : [ - "python", - "c++", - "ruby" - ], - "indent" : { - "length" : 3, - "use_space" : true - } -} ------------------------------------------------------------------------- - -We would first call the parse-method: - ------------------------------------------------------------------------- -//Say that we're using the QtJson namespace -using namespace QtJson; -bool ok; -//json is a QString containing the JSON data -QVariantMap result = Json::parse(json, ok).toMap(); - -if(!ok) { - qFatal("An error occurred during parsing"); - exit(1); -} ------------------------------------------------------------------------- - -Assuming the parsing process completed without errors, we would then -go through the hierarchy: - ------------------------------------------------------------------------- -qDebug() << "encoding:" << result["encoding"].toString(); -qDebug() << "plugins:"; - -foreach(QVariant plugin, result["plug-ins"].toList()) { - qDebug() << "\t-" << plugin.toString(); -} - -QVariantMap nestedMap = result["indent"].toMap(); -qDebug() << "length:" << nestedMap["length"].toInt(); -qDebug() << "use_space:" << nestedMap["use_space"].toBool(); ------------------------------------------------------------------------- - -The previous code would print out the following: - ------------------------------------------------------------------------- -encoding: "UTF-8" -plugins: - - "python" - - "c++" - - "ruby" -length: 3 -use_space: true ------------------------------------------------------------------------- - -To write JSON data from Qt object is as simple as parsing: - ------------------------------------------------------------------------- -QVariantMap map; -map["name"] = "Name"; -map["age"] = 22; - -QByteArray data = Json::serialize(map); ------------------------------------------------------------------------- - -The byte array 'data' contains valid JSON data: - ------------------------------------------------------------------------- -{ - name: "Luis Gustavo", - age: 22, -} ------------------------------------------------------------------------- - - -######################################################################## -4. CONTRIBUTING - -The code is available to download at GitHub. Contribute if you dare! diff --git a/oracle/src/qt-json/json.cpp b/oracle/src/qt-json/json.cpp deleted file mode 100644 index ff739b49d..000000000 --- a/oracle/src/qt-json/json.cpp +++ /dev/null @@ -1,545 +0,0 @@ -/* Copyright 2011 Eeli Reilin. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO - * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * The views and conclusions contained in the software and documentation - * are those of the authors and should not be interpreted as representing - * official policies, either expressed or implied, of Eeli Reilin. - */ - -/** - * \file json.cpp - */ - -#include "json.h" - -#include -#include - -namespace QtJson -{ - -static QString sanitizeString(QString str) -{ - str.replace(QLatin1String("\\"), QLatin1String("\\\\")); - str.replace(QLatin1String("\""), QLatin1String("\\\"")); - str.replace(QLatin1String("\b"), QLatin1String("\\b")); - str.replace(QLatin1String("\f"), QLatin1String("\\f")); - str.replace(QLatin1String("\n"), QLatin1String("\\n")); - str.replace(QLatin1String("\r"), QLatin1String("\\r")); - str.replace(QLatin1String("\t"), QLatin1String("\\t")); - return QString(QLatin1String("\"%1\"")).arg(str); -} - -static QByteArray join(const QList &list, const QByteArray &sep) -{ - QByteArray res; - for (const QByteArray &i : list) { - if (!res.isEmpty()) { - res += sep; - } - res += i; - } - return res; -} - -/** - * parse - */ -QVariant Json::parse(const QString &json) -{ - bool success = true; - return Json::parse(json, success); -} - -/** - * parse - */ -QVariant Json::parse(const QString &json, bool &success) -{ - success = true; - - // Return an empty QVariant if the JSON data is either null or empty - if (!json.isNull() || !json.isEmpty()) { - // We'll start from index 0 - int index = 0; - - // Parse the first value - QVariant value = Json::parseValue(json, index, success); - - // Return the parsed value - return value; - } else { - // Return the empty QVariant - return QVariant(); - } -} - -QByteArray Json::serialize(const QVariant &data) -{ - bool success = true; - return Json::serialize(data, success); -} - -QByteArray Json::serialize(const QVariant &data, bool &success) -{ - QByteArray str; - success = true; - - if (!data.isValid()) // invalid or null? - { - str = "null"; - } - else if ((data.typeId() == QMetaType::Type::QVariantList) || - (data.typeId() == QMetaType::Type::QStringList)) // variant is a list? - { - QList values; - const QVariantList list = data.toList(); - for (const QVariant &v : list) { - QByteArray serializedValue = serialize(v); - if (serializedValue.isNull()) { - success = false; - break; - } - values << serializedValue; - } - - str = "[ " + join(values, ", ") + " ]"; - } - else if ((data.typeId() == QMetaType::Type::QVariantHash)) // variant is a hash? - { - const QVariantHash vhash = data.toHash(); - QHashIterator it(vhash); - str = "{ "; - QList pairs; - - while (it.hasNext()) { - it.next(); - QByteArray serializedValue = serialize(it.value()); - - if (serializedValue.isNull()) { - success = false; - break; - } - - pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; - } - - str += join(pairs, ", "); - str += " }"; - } - else if ((data.typeId() == QMetaType::Type::QVariantMap)) // variant is a map? - { - const QVariantMap vmap = data.toMap(); - QMapIterator it(vmap); - str = "{ "; - QList pairs; - while (it.hasNext()) { - it.next(); - QByteArray serializedValue = serialize(it.value()); - if (serializedValue.isNull()) { - success = false; - break; - } - pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; - } - str += join(pairs, ", "); - str += " }"; - } - else if ((data.typeId() == QMetaType::Type::QString) || - (data.typeId() == QMetaType::Type::QByteArray)) // a string or a byte array? - { - str = sanitizeString(data.toString()).toUtf8(); - } - else if (data.typeId() == QMetaType::Type::Double) // double? - { - str = QByteArray::number(data.toDouble(), 'g', 20); - if (!str.contains(".") && !str.contains("e")) { - str += ".0"; - } - } - else if (data.typeId() == QMetaType::Type::Bool) // boolean value? - { - str = data.toBool() ? "true" : "false"; - } - else if (data.typeId() == QMetaType::Type::ULongLong) // large unsigned number? - { - str = QByteArray::number(data.value()); - } else if (data.canConvert()) // any signed number? - { - str = QByteArray::number(data.value()); - } else if (data.canConvert()) { - str = QString::number(data.value()).toUtf8(); - } else if (data.canConvert()) // can value be converted to string? - { - // this will catch QDate, QDateTime, QUrl, ... - str = sanitizeString(data.toString()).toUtf8(); - } else { - success = false; - } - if (success) { - return str; - } else { - return QByteArray(); - } -} - -/** - * parseValue - */ -QVariant Json::parseValue(const QString &json, int &index, bool &success) -{ - // Determine what kind of data we should parse by - // checking out the upcoming token - switch (Json::lookAhead(json, index)) { - case JsonTokenString: - return Json::parseString(json, index, success); - case JsonTokenNumber: - return Json::parseNumber(json, index); - case JsonTokenCurlyOpen: - return Json::parseObject(json, index, success); - case JsonTokenSquaredOpen: - return Json::parseArray(json, index, success); - case JsonTokenTrue: - Json::nextToken(json, index); - return QVariant(true); - case JsonTokenFalse: - Json::nextToken(json, index); - return QVariant(false); - case JsonTokenNull: - Json::nextToken(json, index); - return QVariant(); - case JsonTokenNone: - break; - } - - // If there were no tokens, flag the failure and return an empty QVariant - success = false; - return QVariant(); -} - -/** - * parseObject - */ -QVariant Json::parseObject(const QString &json, int &index, bool &success) -{ - QVariantMap map; - int token; - - // Get rid of the whitespace and increment index - Json::nextToken(json, index); - - // Loop through all of the key/value pairs of the object - bool done = false; - while (!done) { - // Get the upcoming token - token = Json::lookAhead(json, index); - - if (token == JsonTokenNone) { - success = false; - return QVariantMap(); - } else if (token == JsonTokenComma) { - Json::nextToken(json, index); - } else if (token == JsonTokenCurlyClose) { - Json::nextToken(json, index); - return map; - } else { - // Parse the key/value pair's name - QString name = Json::parseString(json, index, success).toString(); - - if (!success) { - return QVariantMap(); - } - - // Get the next token - token = Json::nextToken(json, index); - - // If the next token is not a colon, flag the failure - // return an empty QVariant - if (token != JsonTokenColon) { - success = false; - return QVariant(QVariantMap()); - } - - // Parse the key/value pair's value - QVariant value = Json::parseValue(json, index, success); - - if (!success) { - return QVariantMap(); - } - - // Assign the value to the key in the map - map[name] = value; - } - } - - // Return the map successfully - return QVariant(map); -} - -/** - * parseArray - */ -QVariant Json::parseArray(const QString &json, int &index, bool &success) -{ - QVariantList list; - - Json::nextToken(json, index); - - bool done = false; - while (!done) { - int token = Json::lookAhead(json, index); - - if (token == JsonTokenNone) { - success = false; - return QVariantList(); - } else if (token == JsonTokenComma) { - Json::nextToken(json, index); - } else if (token == JsonTokenSquaredClose) { - Json::nextToken(json, index); - break; - } else { - QVariant value = Json::parseValue(json, index, success); - - if (!success) { - return QVariantList(); - } - - list.push_back(value); - } - } - - return QVariant(list); -} - -/** - * parseString - */ -QVariant Json::parseString(const QString &json, int &index, bool &success) -{ - QString s; - QChar c; - - Json::eatWhitespace(json, index); - - c = json[index++]; - - bool complete = false; - while (!complete) { - if (index == json.size()) { - break; - } - - c = json[index++]; - - if (c == '\"') { - complete = true; - break; - } else if (c == '\\') { - if (index == json.size()) { - break; - } - - c = json[index++]; - - if (c == '\"') { - s.append('\"'); - } else if (c == '\\') { - s.append('\\'); - } else if (c == '/') { - s.append('/'); - } else if (c == 'b') { - s.append('\b'); - } else if (c == 'f') { - s.append('\f'); - } else if (c == 'n') { - s.append('\n'); - } else if (c == 'r') { - s.append('\r'); - } else if (c == 't') { - s.append('\t'); - } else if (c == 'u') { - int remainingLength = json.size() - index; - - if (remainingLength >= 4) { - QString unicodeStr = json.mid(index, 4); - - int symbol = unicodeStr.toInt(0, 16); - - s.append(QChar(symbol)); - - index += 4; - } else { - break; - } - } - } else { - s.append(c); - } - } - - if (!complete) { - success = false; - return QVariant(); - } - - return QVariant(s); -} - -/** - * parseNumber - */ -QVariant Json::parseNumber(const QString &json, int &index) -{ - Json::eatWhitespace(json, index); - - int lastIndex = Json::lastIndexOfNumber(json, index); - int charLength = (lastIndex - index) + 1; - QString numberStr; - - numberStr = json.mid(index, charLength); - - index = lastIndex + 1; - - if (numberStr.contains('.')) { - return QVariant(numberStr.toDouble(NULL)); - } else if (numberStr.startsWith('-')) { - return QVariant(numberStr.toLongLong(NULL)); - } else { - return QVariant(numberStr.toULongLong(NULL)); - } -} - -/** - * lastIndexOfNumber - */ -int Json::lastIndexOfNumber(const QString &json, int index) -{ - static const QString numericCharacters("0123456789+-.eE"); - int lastIndex; - - for (lastIndex = index; lastIndex < json.size(); lastIndex++) { - if (numericCharacters.indexOf(json[lastIndex]) == -1) { - break; - } - } - - return lastIndex - 1; -} - -/** - * eatWhitespace - */ -void Json::eatWhitespace(const QString &json, int &index) -{ - static const QString whitespaceChars(" \t\n\r"); - for (; index < json.size(); index++) { - if (whitespaceChars.indexOf(json[index]) == -1) { - break; - } - } -} - -/** - * lookAhead - */ -int Json::lookAhead(const QString &json, int index) -{ - int saveIndex = index; - return Json::nextToken(json, saveIndex); -} - -/** - * nextToken - */ -int Json::nextToken(const QString &json, int &index) -{ - Json::eatWhitespace(json, index); - - if (index == json.size()) { - return JsonTokenNone; - } - - QChar c = json[index]; - index++; - switch (c.toLatin1()) { - case '{': - return JsonTokenCurlyOpen; - case '}': - return JsonTokenCurlyClose; - case '[': - return JsonTokenSquaredOpen; - case ']': - return JsonTokenSquaredClose; - case ',': - return JsonTokenComma; - case '"': - return JsonTokenString; - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - case '-': - return JsonTokenNumber; - case ':': - return JsonTokenColon; - } - - index--; - - int remainingLength = json.size() - index; - - // True - if (remainingLength >= 4) { - if (json[index] == 't' && json[index + 1] == 'r' && json[index + 2] == 'u' && json[index + 3] == 'e') { - index += 4; - return JsonTokenTrue; - } - } - - // False - if (remainingLength >= 5) { - if (json[index] == 'f' && json[index + 1] == 'a' && json[index + 2] == 'l' && json[index + 3] == 's' && - json[index + 4] == 'e') { - index += 5; - return JsonTokenFalse; - } - } - - // Null - if (remainingLength >= 4) { - if (json[index] == 'n' && json[index + 1] == 'u' && json[index + 2] == 'l' && json[index + 3] == 'l') { - index += 4; - return JsonTokenNull; - } - } - - return JsonTokenNone; -} - -} // namespace QtJson diff --git a/oracle/src/qt-json/json.h b/oracle/src/qt-json/json.h deleted file mode 100644 index cf0499d4e..000000000 --- a/oracle/src/qt-json/json.h +++ /dev/null @@ -1,204 +0,0 @@ -/* Copyright 2011 Eeli Reilin. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO - * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * The views and conclusions contained in the software and documentation - * are those of the authors and should not be interpreted as representing - * official policies, either expressed or implied, of Eeli Reilin. - */ - -/** - * \file json.h - */ - -#ifndef JSON_H -#define JSON_H - -#include -#include - -namespace QtJson -{ - -/** - * \enum JsonToken - */ -enum JsonToken -{ - JsonTokenNone = 0, - JsonTokenCurlyOpen = 1, - JsonTokenCurlyClose = 2, - JsonTokenSquaredOpen = 3, - JsonTokenSquaredClose = 4, - JsonTokenColon = 5, - JsonTokenComma = 6, - JsonTokenString = 7, - JsonTokenNumber = 8, - JsonTokenTrue = 9, - JsonTokenFalse = 10, - JsonTokenNull = 11 -}; - -/** - * \class Json - * \brief A JSON data parser - * - * Json parses a JSON data into a QVariant hierarchy. - */ -class Json -{ - public: - /** - * Parse a JSON string - * - * \param json The JSON data - */ - static QVariant parse(const QString &json); - - /** - * Parse a JSON string - * - * \param json The JSON data - * \param success The success of the parsing - */ - static QVariant parse(const QString &json, bool &success); - - /** - * This method generates a textual JSON representation - * - * \param data The JSON data generated by the parser. - * \param success The success of the serialization - */ - static QByteArray serialize(const QVariant &data); - - /** - * This method generates a textual JSON representation - * - * \param data The JSON data generated by the parser. - * \param success The success of the serialization - * - * \return QByteArray Textual JSON representation - */ - static QByteArray serialize(const QVariant &data, bool &success); - - private: - /** - * Parses a value starting from index - * - * \param json The JSON data - * \param index The start index - * \param success The success of the parse process - * - * \return QVariant The parsed value - */ - static QVariant parseValue(const QString &json, int &index, - bool &success); - - /** - * Parses an object starting from index - * - * \param json The JSON data - * \param index The start index - * \param success The success of the object parse - * - * \return QVariant The parsed object map - */ - static QVariant parseObject(const QString &json, int &index, - bool &success); - - /** - * Parses an array starting from index - * - * \param json The JSON data - * \param index The starting index - * \param success The success of the array parse - * - * \return QVariant The parsed variant array - */ - static QVariant parseArray(const QString &json, int &index, - bool &success); - - /** - * Parses a string starting from index - * - * \param json The JSON data - * \param index The starting index - * \param success The success of the string parse - * - * \return QVariant The parsed string - */ - static QVariant parseString(const QString &json, int &index, - bool &success); - - /** - * Parses a number starting from index - * - * \param json The JSON data - * \param index The starting index - * - * \return QVariant The parsed number - */ - static QVariant parseNumber(const QString &json, int &index); - - /** - * Get the last index of a number starting from index - * - * \param json The JSON data - * \param index The starting index - * - * \return The last index of the number - */ - static int lastIndexOfNumber(const QString &json, int index); - - /** - * Skip unwanted whitespace symbols starting from index - * - * \param json The JSON data - * \param index The start index - */ - static void eatWhitespace(const QString &json, int &index); - - /** - * Check what token lies ahead - * - * \param json The JSON data - * \param index The starting index - * - * \return int The upcoming token - */ - static int lookAhead(const QString &json, int index); - - /** - * Get the next JSON token - * - * \param json The JSON data - * \param index The starting index - * - * \return int The next JSON token - */ - static int nextToken(const QString &json, int &index); -}; - - -} //end namespace - -#endif //JSON_H 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/servatrice/src/main.cpp b/servatrice/src/main.cpp index 9e7fe38d9..13bf95a82 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include RNG_Abstract *rng; @@ -169,7 +170,7 @@ int main(int argc, char *argv[]) signalhandler = new SignalHandler(); - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); std::cerr << "Servatrice " << VERSION_STRING << " starting." << std::endl; std::cerr << "-------------------------" << std::endl; 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 c5c1e9097..9bc5ee5be 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,3 +7,63 @@ endif() target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) 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/src/raw_json_scanner.cpp oracle_importer_test.cpp +) + +if(NOT GTEST_FOUND) + add_dependencies(oracle_importer_test gtest) +endif() + +target_link_libraries( + oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) + +add_test(NAME oracle_importer_test COMMAND oracle_importer_test) + +# 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/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES} +) + +if(NOT GTEST_FOUND) + add_dependencies(oracle_importer_benchmark_test gtest) +endif() + +target_link_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 new file mode 100644 index 000000000..633c9e40a --- /dev/null +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -0,0 +1,578 @@ +#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) +{ + QJsonObject dataObj; + for (int s = 0; s < numSets; ++s) { + QJsonArray cardsArray; + for (int c = 0; c < cardsPerSet; ++c) { + QJsonObject card; + card["name"] = QString("Card %1").arg(s * cardsPerSet + c); + card["text"] = "This is a test card with some rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["power"] = "2"; + card["toughness"] = "2"; + card["colors"] = QJsonArray{"W"}; + card["colorIdentity"] = QJsonArray{"W"}; + card["types"] = QJsonArray{"Creature"}; + // Real MTGJSON types: floats and booleans, not strings. This + // exercises the QVariant coercion in the property reader. + card["convertedManaCost"] = 1.0; + card["manaValue"] = 1.0; + card["isOnlineOnly"] = false; + card["isRebalanced"] = false; + + QJsonObject legalities; + legalities["standard"] = "legal"; + legalities["modern"] = "legal"; + legalities["legacy"] = "legal"; + legalities["vintage"] = "legal"; + legalities["commander"] = "legal"; + card["legalities"] = legalities; + + QJsonObject identifiers; + identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c); + card["identifiers"] = identifiers; + + // In AllPrintings, number and rarity are flat fields on the card + // object, exactly as set below. + card["number"] = QString::number(c + 1); + card["rarity"] = "common"; + + cardsArray.append(card); + } + + QJsonObject setObj; + setObj["code"] = QString("T%1").arg(s, 2, 10, QChar('0')); + setObj["name"] = QString("Test Set %1").arg(s); + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = cardsArray; + + dataObj[QString("T%1").arg(s, 2, 10, QChar('0'))] = setObj; + } + + QJsonObject root; + root["data"] = dataObj; + return QJsonDocument(root).toJson(QJsonDocument::Compact); +} + +// ============================================================================ +// Import throughput benchmark +// ============================================================================ + +TEST(OracleBenchmark, ImportThroughput) +{ + static constexpr int numSets = 10; + static constexpr int cardsPerSet = 500; + + QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + OracleImporter importer; + + // Phase 1: Parse JSON + QElapsedTimer timer; + timer.start(); + bool ok = importer.readSetsFromByteArray(data); + ASSERT_TRUE(ok); + qint64 parseMs = timer.elapsed(); + + // Phase 2: Import cards + timer.restart(); + int importedSets = importer.startImport(); + qint64 importMs = timer.elapsed(); + + int totalImported = 0; + for (const auto &card : importer.getCardList()) { + Q_UNUSED(card); + totalImported++; + } + + // The fixture generates globally unique card names, so the expected + // counts are exact: a regression here means cards were dropped. + ASSERT_EQ(importedSets, numSets); + ASSERT_EQ(totalImported, numSets * cardsPerSet); + // Real-data probe: numeric convertedManaCost must be coerced to text + // (regression for the QJsonValue::toString() reader in #7214). + auto probeCard = importer.getCardList().value("Card 0"); + ASSERT_FALSE(probeCard.isNull()); + ASSERT_EQ(probeCard->getProperty("cmc"), "1"); + + qDebug().noquote() + << QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + qDebug().noquote() << QString(" Total: %1 ms").arg(parseMs + importMs); + if (importMs > 0) { + qDebug().noquote() << QString(" Throughput: %1 cards/sec") + .arg(static_cast(totalImported) / importMs * 1000.0, 0, 'f', 0); + } +} + +// ============================================================================ +// readSetsFromByteArray benchmark +// ============================================================================ + +TEST(OracleBenchmark, ParseJsonThroughput) +{ + static constexpr int numSets = 20; + static constexpr int cardsPerSet = 1000; + + QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + // Run 5 iterations and report average + static constexpr int iterations = 5; + qint64 totalMs = 0; + + for (int i = 0; i < iterations; ++i) { + OracleImporter importer; + QByteArray source = data; + QElapsedTimer timer; + timer.start(); + bool ok = importer.readSetsFromByteArray(std::move(source)); + ASSERT_TRUE(ok); + totalMs += timer.elapsed(); + } + + qint64 avgMs = totalMs / iterations; + qDebug().noquote() << QString("Parse Benchmark (%1 iterations): avg %2 ms for %3 sets x %4 cards") + .arg(iterations) + .arg(avgMs) + .arg(numSets) + .arg(cardsPerSet); +} + +// ============================================================================ +// Split card merging benchmark +// ============================================================================ + +TEST(OracleBenchmark, SplitCardMerging) +{ + static constexpr int numSplitCards = 1000; + + QJsonArray cardsList; + for (int i = 0; i < numSplitCards; ++i) { + QJsonObject face1; + face1["name"] = QString("Fire %1 // Ice %1").arg(i); + face1["text"] = "Fire side text."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = QString("Fire %1").arg(i); + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = QJsonObject{{"standard", "not_legal"}}; + face1["identifiers"] = QJsonObject{{"scryfallId", QString("f-%1").arg(i)}}; + face1["number"] = QString::number(i + 1); + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = QString("Fire %1 // Ice %1").arg(i); + face2["text"] = "Ice side text."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = QString("Ice %1").arg(i); + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = QJsonObject{{"standard", "not_legal"}}; + face2["identifiers"] = QJsonObject{{"scryfallId", QString("i-%1").arg(i)}}; + face2["number"] = QString::number(i + 1); + face2["rarity"] = "uncommon"; + + cardsList.append(face1); + cardsList.append(face2); + } + + NoopCardSetPriorityController controller; + OracleImporter importer; + CardSetPtr set = CardSet::newInstance(&controller, "TST", "Split Test"); + + QElapsedTimer timer; + timer.start(); + int count = importer.importCardsFromSet(set, cardsList); + qint64 ms = timer.elapsed(); + + ASSERT_EQ(count, numSplitCards); + qDebug().noquote() << QString("Split Card Merge Benchmark: %1 cards in %2 ms (%3 cards/sec)") + .arg(count) + .arg(ms) + .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); +} + +// ============================================================================ +// sortAndReduceColors microbenchmark +// ============================================================================ + +// We can't call sortAndReduceColors directly (it's static), so we benchmark +// through importCardsFromSet with color properties. + +TEST(OracleBenchmark, ImportCardsWithColors) +{ + static constexpr int numCards = 10000; + + NoopCardSetPriorityController controller; + OracleImporter importer; + CardSetPtr set = CardSet::newInstance(&controller, "TST", "Color Test"); + + QJsonArray cardsList; + for (int i = 0; i < numCards; ++i) { + QJsonObject card; + card["name"] = QString("Color Card %1").arg(i); + card["text"] = "Rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["types"] = QJsonArray{"Creature"}; + card["colors"] = QJsonArray{"B", "R", "G", "W", "U"}; + card["colorIdentity"] = QJsonArray{"B", "R", "G", "W", "U"}; + card["number"] = QString::number(i + 1); + card["rarity"] = "common"; + card["legalities"] = QJsonObject{{"standard", "legal"}}; + card["identifiers"] = QJsonObject{{"scryfallId", QString("c-%1").arg(i)}}; + cardsList.append(card); + } + + QElapsedTimer timer; + timer.start(); + int count = importer.importCardsFromSet(set, cardsList); + qint64 ms = timer.elapsed(); + + ASSERT_EQ(count, numCards); + qDebug().noquote() << QString("Import with Colors Benchmark: %1 cards in %2 ms (%3 cards/sec)") + .arg(count) + .arg(ms) + .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 new file mode 100644 index 000000000..3834be9ed --- /dev/null +++ b/tests/oracle/oracle_importer_test.cpp @@ -0,0 +1,748 @@ +#include "../../oracle/src/oracleimporter.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include + +class OracleImporterTest : public ::testing::Test +{ +protected: + void SetUp() override + { + controller = new NoopCardSetPriorityController(); + importer = new OracleImporter(); + set = CardSet::newInstance(controller, "TST", "Test Set"); + } + + void TearDown() override + { + delete importer; + delete controller; + } + + // Helper: build a minimal card JSON object + QJsonObject makeCard(const QString &name, + const QString &colors = "", + const QString &colorIdentity = "", + const QVariantMap &legalities = {}) + { + QJsonObject card; + card["name"] = name; + card["text"] = "Rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["types"] = QJsonArray{"Creature"}; + card["number"] = "1"; + card["rarity"] = "common"; + + if (!colors.isEmpty()) { + QJsonArray arr; + for (const QChar &c : colors) { + arr.append(QString(c)); + } + card["colors"] = arr; + } + if (!colorIdentity.isEmpty()) { + QJsonArray arr; + for (const QChar &c : colorIdentity) { + arr.append(QString(c)); + } + card["colorIdentity"] = arr; + } + if (!legalities.isEmpty()) { + QJsonObject legalObj; + for (auto it = legalities.constBegin(); it != legalities.constEnd(); ++it) { + legalObj[it.key()] = it.value().toString(); + } + card["legalities"] = legalObj; + } + + QJsonObject identifiers; + identifiers["scryfallId"] = QUuid::createUuid().toString(QUuid::WithoutBraces); + card["identifiers"] = identifiers; + + return card; + } + + NoopCardSetPriorityController *controller; + OracleImporter *importer; + CardSetPtr set; +}; + +// ============================================================================ +// sortAndReduceColors tests (tested via importCardsFromSet) +// ============================================================================ + +TEST_F(OracleImporterTest, SortAndReduceColorsSingleColor) +{ + QJsonArray cards{makeCard("Red Card", "R", "R")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Red Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "R"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsDeduplicates) +{ + QJsonArray cards{makeCard("Dedup Card", "WWUUB", "WU")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Dedup Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WUB"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsSortsWUBRG) +{ + QJsonArray cards{makeCard("Sort Card", "RGW", "RGW")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Sort Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WRG"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsAllFive) +{ + QJsonArray cards{makeCard("Five Color", "BRGWU", "BRGWU")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Five Color"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WUBRG"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorIdentity) +{ + QJsonArray cards{makeCard("Color Id Card", "W", "GWR")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Color Id Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("coloridentity"), "WRG"); +} + +TEST_F(OracleImporterTest, SingleColorNotSorted) +{ + QJsonArray cards{makeCard("Single Card", "B", "B")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Single Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "B"); +} + +// ============================================================================ +// Legality guard tests +// ============================================================================ + +TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties) +{ + // Verifies that format-* properties survive addCard on a fresh card + // (not the combineLegalities guard, which only runs on existing printings). + QVariantMap leg; + leg["standard"] = "legal"; + leg["modern"] = "legal"; + QJsonArray cards{makeCard("Legal Card", "", "", leg)}; + + importer->importCardsFromSet(set, cards); + auto card = importer->getCardList().value("Legal Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("format-standard"), "legal"); + ASSERT_EQ(card->getProperty("format-modern"), "legal"); +} + +TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities) +{ + // First printing carries no legalities at all, so the guard's + // `properties.filter(formatRegex).empty()` predicate is true and the + // second printing's legalities must be merged in. + QJsonArray cards1{makeCard("Unmerged Card")}; + importer->importCardsFromSet(set, cards1); + + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QVariantMap leg; + leg["standard"] = "legal"; + QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)}; + importer->importCardsFromSet(set2, cards2); + + auto card = importer->getCardList().value("Unmerged Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("format-standard"), "legal"); +} + +TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting) +{ + // First printing: standard=legal, modern=legal + QVariantMap leg1; + leg1["standard"] = "legal"; + leg1["modern"] = "legal"; + QJsonArray cards1{makeCard("Guarded Card", "", "", leg1)}; + importer->importCardsFromSet(set, cards1); + + // Second printing: standard=banned, modern=not_legal + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QVariantMap leg2; + leg2["standard"] = "banned"; + leg2["modern"] = "not_legal"; + QJsonArray cards2{makeCard("Guarded Card", "", "", leg2)}; + importer->importCardsFromSet(set2, cards2); + + auto card = importer->getCardList().value("Guarded Card"); + ASSERT_FALSE(card.isNull()); + // Guard should preserve first printing's legalities + ASSERT_EQ(card->getProperty("format-standard"), "legal"); + ASSERT_EQ(card->getProperty("format-modern"), "legal"); +} + +// ============================================================================ +// createDefaultMagicFormats tests +// ============================================================================ + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsContainsExpectedFormats) +{ + auto formats = importer->createDefaultMagicFormats(); + ASSERT_TRUE(formats.contains("standard")); + ASSERT_TRUE(formats.contains("modern")); + ASSERT_TRUE(formats.contains("legacy")); + ASSERT_TRUE(formats.contains("vintage")); + ASSERT_TRUE(formats.contains("commander")); + ASSERT_TRUE(formats.contains("pauper")); + ASSERT_TRUE(formats.contains("pioneer")); + ASSERT_TRUE(formats.contains("brawl")); + ASSERT_TRUE(formats.contains("historic")); + ASSERT_TRUE(formats.contains("timeless")); + ASSERT_TRUE(formats.contains("duel")); + ASSERT_TRUE(formats.contains("oathbreaker")); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes) +{ + auto formats = importer->createDefaultMagicFormats(); + auto commander = formats.value("commander"); + ASSERT_FALSE(commander.isNull()); + ASSERT_EQ(commander->minDeckSize, 100); + ASSERT_EQ(commander->maxDeckSize, 100); + ASSERT_EQ(commander->maxSideboardSize, 15); + + auto brawl = formats.value("brawl"); + ASSERT_FALSE(brawl.isNull()); + ASSERT_EQ(brawl->minDeckSize, 60); + ASSERT_EQ(brawl->maxDeckSize, 60); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted) +{ + auto formats = importer->createDefaultMagicFormats(); + auto vintage = formats.value("vintage"); + ASSERT_FALSE(vintage.isNull()); + bool hasRestricted = false; + for (const auto &ac : vintage->allowedCounts) { + if (ac.label == "restricted") { + hasRestricted = true; + ASSERT_EQ(ac.max, 1); + } + } + ASSERT_TRUE(hasRestricted); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands) +{ + auto formats = importer->createDefaultMagicFormats(); + auto standard = formats.value("standard"); + ASSERT_FALSE(standard.isNull()); + ASSERT_FALSE(standard->exceptions.isEmpty()); + + auto &basicLandsException = standard->exceptions.first(); + ASSERT_FALSE(basicLandsException.conditions.isEmpty()); + + auto &condition = basicLandsException.conditions.first(); + ASSERT_EQ(condition.field, "type"); + ASSERT_EQ(condition.matchType, "regex"); + + // Verify the regex actually works (was broken before: \b = backspace, not word boundary) + QRegularExpression regex(condition.value); + ASSERT_TRUE(regex.isValid()); + ASSERT_TRUE(regex.match("Basic Land — Forest").hasMatch()); + ASSERT_TRUE(regex.match("Basic Snow Land — Mountain").hasMatch()); + ASSERT_FALSE(regex.match("Creature — Elf Warrior").hasMatch()); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching) +{ + // The memoized map returns the same FormatRulesPtr instances, so the + // shared pointers must be identical across calls. This is the only + // observable effect of the cache: contents would match either way. + auto first = importer->createDefaultMagicFormats(); + auto second = importer->createDefaultMagicFormats(); + ASSERT_EQ(first.value("standard").data(), second.value("standard").data()); +} + +// ============================================================================ +// readSetsFromByteArray tests +// ============================================================================ + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayValidJson) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(importer->getSets().size(), 1); + ASSERT_EQ(importer->getSets().first().getShortName(), "TST"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayInvalidJson) +{ + QByteArray data = "not valid json"; + ASSERT_FALSE(importer->readSetsFromByteArray(data)); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmptyData) +{ + QJsonObject root; + root["data"] = QJsonObject(); + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_FALSE(importer->readSetsFromByteArray(data)); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType) +{ + QJsonObject setObj; + setObj["code"] = "ftv"; + setObj["name"] = "From The Vault"; + setObj["type"] = "from_the_vault"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"FTV", setObj}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(importer->getSets().first().getSetType(), "From the Vault"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName) +{ + // QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"), + // so leaving the natural order matching the alphabetical sort makes the + // assertion pass trivially. Inverting it keeps the sort meaningful: + // iteration yields "AAA" (Zeta Set) first, then the sort by name must + // promote "ZZZ" (Alpha Set) to the front. + QJsonObject setA; + setA["code"] = "aaa"; + setA["name"] = "Zeta Set"; + setA["type"] = "expansion"; + setA["releaseDate"] = "2024-01-01"; + setA["cards"] = QJsonArray(); + + QJsonObject setB; + setB["code"] = "zzz"; + setB["name"] = "Alpha Set"; + setB["type"] = "expansion"; + setB["releaseDate"] = "2024-01-01"; + setB["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + auto sets = importer->getSets(); + ASSERT_GE(sets.size(), 2); + ASSERT_EQ(sets.first().getShortName(), "ZZZ"); +} + +// ============================================================================ +// Split card coloridentity tests +// ============================================================================ + +TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated) +{ + QJsonObject leg{{"standard", "not_legal"}}; + + QJsonObject face1; + face1["name"] = "Fire // Ice"; + face1["text"] = "Fire deals 2 damage."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = leg; + face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; + face1["number"] = "1"; + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = "Fire // Ice"; + face2["text"] = "Ice taps target artifact."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = leg; + face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; + face2["number"] = "1"; + face2["rarity"] = "uncommon"; + + QJsonArray cardsList{face1, face2}; + int count = importer->importCardsFromSet(set, cardsList); + ASSERT_EQ(count, 1); + + auto card = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(card.isNull()); + + // coloridentity should be "RU" (concatenated), then sorted to "UR" + // by sortAndReduceColors when it reaches addCard + ASSERT_EQ(card->getProperty("coloridentity"), "UR"); +} + +TEST_F(OracleImporterTest, SplitCardColorsConcatenated) +{ + QJsonObject leg{{"standard", "not_legal"}}; + + QJsonObject face1; + face1["name"] = "Fire // Ice"; + face1["text"] = "Fire deals 2 damage."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = leg; + face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; + face1["number"] = "1"; + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = "Fire // Ice"; + face2["text"] = "Ice taps target artifact."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = leg; + face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; + face2["number"] = "1"; + face2["rarity"] = "uncommon"; + + QJsonArray cardsList{face1, face2}; + importer->importCardsFromSet(set, cardsList); + + auto card = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(card.isNull()); + + QString colors = card->getProperty("colors"); + ASSERT_FALSE(colors.contains("//")) << "colors should not contain '//', got: " << colors.toStdString(); + ASSERT_TRUE(colors.contains("R")); + ASSERT_TRUE(colors.contains("U")); +} + +// ============================================================================ +// Mana cost formatting tests +// ============================================================================ + +TEST_F(OracleImporterTest, ManaCostStripsBraces) +{ + QJsonObject card = makeCard("Mana Card"); + card["manaCost"] = "{2}{W}{B}"; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Mana Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("manacost"), "2WB"); +} + +// cmc comes through as a JSON number ("convertedManaCost"/"manaValue" are +// floats in AllPrintings), so this pins the number-to-text coercion that +// QJsonValue::toString() dropped in #7214. +TEST_F(OracleImporterTest, NumericManaValueCoercedToCmc) +{ + QJsonObject card = makeCard("Cmc Card"); + card["manaValue"] = 3; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Cmc Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("cmc"), "3"); +} + +TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) +{ + QJsonObject card = makeCard("Legacy Cmc Card"); + card["convertedManaCost"] = 3.0; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Legacy Cmc Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("cmc"), "3"); +} + +// ============================================================================ +// Card deduplication tests +// ============================================================================ + +TEST_F(OracleImporterTest, DuplicateCardNameReturnsExisting) +{ + QJsonArray cards{makeCard("Dupe Card")}; + importer->importCardsFromSet(set, cards); + + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QJsonArray cards2{makeCard("Dupe Card")}; + importer->importCardsFromSet(set2, cards2); + + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, AELigatureReplaced) +{ + QJsonObject card = makeCard(QString::fromUtf8("\xC3\x86ther Vial")); // Æther Vial + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + // Æ is replaced with AE, resulting in "AEther Vial" + ASSERT_FALSE(importer->getCardList().contains(QString::fromUtf8("\xC3\x86ther Vial"))); + ASSERT_TRUE(importer->getCardList().contains("AEther Vial")); +} + +TEST_F(OracleImporterTest, ApostropheNormalized) +{ + QJsonObject card = makeCard(QString::fromUtf8("Jace\u2019s Ingenuity")); + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + 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); + return RUN_ALL_TESTS(); +} diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 38d9b6315..2b8f8bdb7 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,25 +1,9 @@ #include "gtest/gtest.h" -#include -#include +#include #include -RNG_Abstract *rng; - namespace { -class PasswordHashTest : public ::testing::Test -{ -protected: - void SetUp() override - { - rng = new RNG_SFMT; - } - - void TearDown() override - { - delete rng; - } -}; TEST(PasswordHashTest, RegressionTest) { @@ -29,6 +13,29 @@ TEST(PasswordHashTest, RegressionTest) QString hash = PasswordHasher::computeHash(password, salt); ASSERT_EQ(hash, salt + expected) << "The computed hash value remains the same"; } + +TEST(PasswordHashTest, SaltUsesAlphanumericCharset) +{ + static const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const QString salt = PasswordHasher::generateRandomSalt(); + ASSERT_EQ(salt.size(), 16); + for (const QChar &c : salt) { + ASSERT_NE(strchr(alphanum, c.toLatin1()), nullptr); + } +} + +TEST(PasswordHashTest, SaltsAreUnique) +{ + const QString salt1 = PasswordHasher::generateRandomSalt(); + const QString salt2 = PasswordHasher::generateRandomSalt(); + ASSERT_NE(salt1, salt2); +} + +TEST(PasswordHashTest, TokenHasExpectedLength) +{ + const QString token = PasswordHasher::generateActivationToken(); + ASSERT_EQ(token.size(), 16); +} } // namespace int main(int argc, char **argv) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 6c79d5227..139656f27 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -238,6 +238,12 @@ TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default) ASSERT_EQ(s.getTabModerationOpen(), false); } +TEST_F(SettingsDefaultsTest, Tabs_CardArtRulesOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabCardArtRulesOpen(), false); +} + // --- ChatSettings --- TEST_F(SettingsDefaultsTest, Chat_Mention_Default)