diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index b08e568f3..f37315262 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -8,7 +8,6 @@ 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 e3df94ab5..0fa227d6f 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -15,7 +15,6 @@ 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 60e490c98..13e8b35c7 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -16,7 +16,6 @@ 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 4005bbf67..68e894543 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -7,7 +7,6 @@ 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 e0224cdc6..ffd7c1b9b 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -7,7 +7,6 @@ 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 321aa7c0f..21f6a036e 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -12,7 +12,6 @@ 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 10adc5e64..12320c276 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -15,7 +15,6 @@ 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 1b6cf825f..ce3d9cd6c 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -16,7 +16,6 @@ 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 bd8c900c8..8a16d3243 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -149,9 +149,6 @@ 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 b631f32d3..04037a74e 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -176,12 +176,8 @@ jobs: shell: bash run: | source .ci/docker.sh - 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[@]}" + RUN --server --debug --test --ccache "$CCACHE_SIZE" \ + --cmake-generator "$CMAKE_GENERATOR" - name: "Build release package" id: build diff --git a/CMakeLists.txt b/CMakeLists.txt index 0da073464..bac46c2bc 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.. -# 3.16 required for Qt6 and target_precompile_headers() -cmake_minimum_required(VERSION 3.16) +# cmake 3.16 is required if using qt6 +cmake_minimum_required(VERSION 3.10) # Use compiler cache (ccache) -option(USE_CCACHE "Cache the build results with ccache" ON) +option(USE_CCACHE "Cache the build results with ccache" OFF) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates @@ -39,24 +39,13 @@ else() ) endif() -# 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) +if(USE_CCACHE) 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) @@ -195,9 +184,6 @@ 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") @@ -206,9 +192,6 @@ 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 @@ -256,6 +239,11 @@ 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 7d3deb5fb..382309d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ RUN apt-get update \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ - libssl-dev \ qt6-websockets-dev \ protobuf-compiler \ qt6-tools-dev \ @@ -43,7 +42,6 @@ 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 deleted file mode 100644 index cc3dd12ee..000000000 --- a/cmake/pch/qtcore_pch.h +++ /dev/null @@ -1,24 +0,0 @@ -/** @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 deleted file mode 100644 index 2c63f450e..000000000 --- a/cmake/pch/qtwidgets_pch.h +++ /dev/null @@ -1,30 +0,0 @@ -/** @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 b6050a1bd..2f629fed2 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -214,7 +214,6 @@ 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 @@ -517,8 +516,6 @@ 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 fd0a12507..0c8bdb450 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,7 +52,6 @@ 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 1acd02a75..1278737a0 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, @@ -380,10 +381,12 @@ void DeckViewScene::rebuildTree() addItem(container); } - // 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 j = 0; j < currentZone->size(); j++) { + auto *currentCard = dynamic_cast(currentZone->at(j)); + if (!currentCard) { + continue; + } + 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 457f1b3f7..87af4c73c 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -44,16 +44,11 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { - // 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(); + // 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); delete animationTimer; animationTimer = nullptr; @@ -782,15 +777,8 @@ void GameScene::registerAnimationItem(IAnimatedItem *item) if (!object) { return; } - // 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)); + if (!animatedItems.contains(object)) { + connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem); } animatedItems.insert(object, item); if (animationTimer && !animationTimer->isActive()) { @@ -809,7 +797,6 @@ 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 859d7a6eb..c12696189 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -54,11 +54,9 @@ private: QPointer hoveredCard; ///< Currently hovered card QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating - 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 + 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 08cb6cac9..7eb3945b3 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -12,13 +12,11 @@ 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(); } @@ -56,5 +54,4 @@ 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 11802fd20..acd1daf67 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.h +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -24,7 +24,6 @@ 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 7e05c3fb1..e7a6621fa 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -34,31 +34,3 @@ 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 e499587eb..4c3d93b56 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.h +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -16,14 +16,6 @@ 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 21806ee84..aa2cae024 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -21,8 +21,6 @@ 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 84c54918f..97406cddb 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -21,8 +21,7 @@ enum class TallyType None, Subtypes, TotalPower, - TotalToughness, - MaxValue = TotalToughness // sentinel value + MaxValue = TotalPower // 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 1a8f7a910..b52a4955a 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -41,8 +41,7 @@ void HandZone::handleDropEvent(const QList &dragItems, } } } else { - bool sameZone = startZone == getLogic(); - x = calcDropIndexFromY(dropPoint.y(), !sameZone); + x = calcDropIndexFromY(dropPoint.y()); } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index 470c70fcf..c58c41b92 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, bool allowCountExpand, qreal minOffset) const +int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const { const auto &cards = getLogic()->getCards(); if (cards.isEmpty()) { @@ -94,8 +94,7 @@ int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal min if (effectiveOffset <= 0.0) { return 0; } - int max = allowCountExpand ? params.cardCount : params.cardCount - 1; - return qBound(0, qRound((dropY - start) / effectiveOffset), max); + return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1); } void SelectZone::restoreStaleEscapedCards() diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index b5d3ca37a..7408f29b6 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -104,12 +104,8 @@ 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, bool allowCountExpand, qreal minOffset = 0.0) const; + int calcDropIndexFromY(qreal dropY, 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 ff62097c7..e9b14f13d 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -57,14 +57,18 @@ void StackZone::handleDropEvent(const QList &dragItems, return; } - bool sameZone = startZone == getLogic(); - int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); - if (sameZone) { + const auto &cards = getLogic()->getCards(); + int index; + if (startZone == getLogic()) { + // Reordering within the zone: use drop position + index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE); // 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 f03339da8..39a0c1071 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -375,32 +375,15 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber, - const QString &boardZoneName) + bool addSetNameAndNumber) { - // 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"; @@ -428,30 +411,25 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); + saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); 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, - const QString &boardZoneName) + bool addSetNameAndNumber) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (boardZoneName == DECK_ZONE_SIDE && addComments) { + if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -532,26 +510,9 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { - if (!node || node->isEmpty()) { - return; - } - const int totalColumns = 2; - // 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()) { + if (node->height() == 1) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -562,9 +523,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); - for (int i = 0; i < cards.size(); i++) { - const AbstractDecklistCardNode *card = cards[i]; + QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); + for (int i = 0; i < node->size(); i++) { + auto *card = dynamic_cast(node->at(i)); QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -579,13 +540,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } - - for (const InnerDecklistNode *subZone : subZones) { - if (subZone->isEmpty()) { - continue; - } - + } else if (node->height() == 2) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -604,8 +559,10 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); - printDeckListNode(&cellCursor, subZone); + 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))); + } } 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 b851c6895..ac23e1ee0 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -159,13 +159,12 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true, - const QString &boardZoneName = QString()); + bool addSetNameAndNumber = true); static void saveToStream_DeckZoneCards(QTextStream &out, + const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true, - const QString &boardZoneName = QString()); + bool addSetNameAndNumber = true); }; #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 bfbdd7e42..3f36e559c 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,18 +174,16 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - addCardWidgets(QPersistentModelIndex(sourceIndex)); - } -} + QPersistentModelIndex persistent(sourceIndex); -void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) -{ - // Get the card amount - int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); + // Get the card amount + int cardAmount = + sourceIndex.sibling(sourceIndex.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 a3bf70981..2308ccf8d 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,7 +35,6 @@ 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 b00d9db1e..eaf3a67b0 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,7 +5,6 @@ #include "libcockatrice/card/database/card_database_manager.h" #include -#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -52,6 +51,11 @@ 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); @@ -91,18 +95,12 @@ 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, effectiveZoneName, categoryName, - activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); + cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria, + activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, + &DeckCardZoneDisplayWidget::onClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -113,9 +111,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - effectiveZoneName, categoryName, activeGroupCriteria, + zoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -128,18 +126,24 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - if (!trackedIndex.isValid()) { - return; - } + QSortFilterProxyModel proxy; + proxy.setSourceModel(deckListModel); + proxy.setSortRole(Qt::EditRole); + proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); - // 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))); - } + // 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); - 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 53f3fa7cf..b426fca30 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,6 +42,7 @@ 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 00388a3cd..7c782b074 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -90,13 +90,6 @@ 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()) { @@ -149,50 +142,6 @@ 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 668444199..175ec12b9 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,7 +4,6 @@ #include "../../key_signals.h" #include -#include #include class CardDatabaseModel; @@ -20,13 +19,6 @@ 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); @@ -41,17 +33,6 @@ 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 6269f0323..2a491de4f 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,12 +1,5 @@ #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"); @@ -22,27 +15,6 @@ 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 e2175a358..14defc8e9 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,18 +7,15 @@ #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 @@ -775,213 +772,14 @@ 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 1e5f4e677..9db01e2e5 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,7 +19,6 @@ #include #include #include -#include #include #include #include @@ -103,11 +102,6 @@ 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 e563729a4..eda741728 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -2,7 +2,6 @@ #include #include -#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), @@ -308,170 +307,6 @@ 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()) { @@ -532,25 +367,6 @@ 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 2c8b34a39..b9c99903e 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,7 +5,6 @@ #include "deck_list_model.h" #include -#include #include class DeckListHistoryManager; @@ -237,68 +236,6 @@ 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. @@ -320,7 +257,6 @@ 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 deleted file mode 100644 index 9a0be2570..000000000 --- a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp +++ /dev/null @@ -1,145 +0,0 @@ -#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 deleted file mode 100644 index 6f55617a8..000000000 --- a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @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 ed0ddaf06..b0dac3e7c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -116,10 +116,9 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) } TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) - : 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) + : 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) { setElideMode(Qt::ElideRight); setMovable(true); @@ -246,7 +245,6 @@ 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; @@ -258,7 +256,6 @@ 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()); @@ -523,9 +520,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabModerationOpen()) { openTabModeration(); } - if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) { - openTabCardArtRules(); - } + openTabCardArtRules(); } retranslateUi(); @@ -587,9 +582,6 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } - if (tabCardArtRules) { - tabCardArtRules->close(); - } } QList tabsToDelete; @@ -783,7 +775,6 @@ 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 0f43893d3..209a30642 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,7 +4,6 @@ #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" @@ -85,7 +84,6 @@ 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); @@ -271,19 +269,6 @@ 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 fb09578c4..21335d2d0 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,12 +165,6 @@ 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 76bbf344b..0cdf60d5d 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,7 +21,6 @@ #include #include #include -#include #include #include @@ -90,19 +89,6 @@ 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()); @@ -209,11 +195,6 @@ 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 d161ce362..6e4d87876 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,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -47,12 +46,6 @@ 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; @@ -113,7 +106,6 @@ 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 d8aa1cd08..84d5d175f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -53,7 +53,6 @@ #include #include #include -#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -293,7 +292,7 @@ int main(int argc, char *argv[]) } } - rng = new RNG_SFMT(CryptoUtil::randomUInt64()); + rng = new RNG_SFMT; themeManager = new ThemeManager; soundEngine = new SoundEngine; diff --git a/format.sh b/format.sh index 9e3a6069b..3fa435be1 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 5d91cd233..af1193f26 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,25 +115,6 @@ 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. * @@ -147,6 +128,8 @@ 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 d082b3cca..ec860dc56 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,13 +43,6 @@ 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); @@ -94,9 +87,6 @@ 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 0d454c11e..906ed6cb5 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,9 +18,6 @@ #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. */ @@ -121,13 +118,6 @@ 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 aaf391c03..25e8e97db 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include static peg::parser search(R"( @@ -20,7 +19,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) +SetQuery <- ('e'/'set') [:] FlexStringValue OracleQuery <- 'o' [:] MatcherString @@ -65,8 +64,6 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ -SetExpression <- NumericOperator ws? String - NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -104,25 +101,12 @@ static void setupParserRules() return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - 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 matcher = std::any_cast(sv[0]); + 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); - }); + QList sets = x->getSets().keys(); + + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -263,54 +247,40 @@ static void setupParserRules() return QString::fromStdString(std::string(sv.sv())); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { - const auto op = 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]); if (op == ">") { - return [=](const int s, const int arg) { return s > arg; }; + return [=](const int s) { return s > arg; }; } if (op == ">=") { - return [=](const int s, const int arg) { return s >= arg; }; + return [=](const int s) { return s >= arg; }; } if (op == "<") { - return [=](const int s, const int arg) { return s < arg; }; + return [=](const int s) { return s < arg; }; } if (op == "<=") { - return [=](const int s, const int arg) { return s <= arg; }; + return [=](const int s) { return s <= arg; }; } if (op == "=") { - return [=](const int s, const int arg) { return s == arg; }; + return [=](const int s) { return s == arg; }; } if (op == ":") { - return [=](const int s, const int arg) { return s == arg; }; + return [=](const int s) { return s == arg; }; } if (op == "!=") { - return [=](const int s, const int arg) { return s != arg; }; + return [=](const int s) { return s != arg; }; } - return [](int, int) { return false; }; + return [](int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - 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["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { + return QString::fromStdString(std::string(sv.sv())); }; 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 a058f7d07..71a99f7b5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,7 +22,6 @@ 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 054c4cd72..a81616cb0 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -21,7 +21,6 @@ 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 a6ab2a204..d4aee3686 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -7,8 +7,7 @@ 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_model_custom_zones.cpp - deck_list_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.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 76afca0c4..9b43281c1 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,8 +66,7 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - // Non-card children are custom zones; they are mirrored in a single - // pass below so each is mirrored exactly once. + //! \todo Better sanity checking. if (currentCard == nullptr) { continue; } @@ -83,19 +82,8 @@ 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(); @@ -166,9 +154,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; - case DeckRoles::IsCustomZoneRole: - return DeckListModelCustomZones::isCustomZone(group); - default: return {}; } @@ -205,10 +190,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } - case DeckRoles::IsCustomZoneRole: { - return false; - } - default: { return {}; } @@ -346,13 +327,6 @@ 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); @@ -363,8 +337,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. - if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { + if (node->empty() && (node != root)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -378,8 +351,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - // Group lookups must not resolve a mirrored custom zone that shares the name. - auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); + auto *newNode = dynamic_cast(parent->findChild(name)); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -393,44 +365,24 @@ 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; } - // 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; - } - } + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); + if (!groupNode) { + return nullptr; } - // 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; + return dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -471,95 +423,29 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } + InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); + CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - InnerDecklistNode *cardParent = nullptr; + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); - 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( + const QModelIndex parentIndex = nodeToIndex(groupNode); + auto *cardNode = dynamic_cast(groupNode->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(cardParent, cardInfo); - int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; + int insertRow = findSortedInsertRow(groupNode, cardInfo); - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); endInsertRows(); cardNodeAdded = true; @@ -690,41 +576,21 @@ 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 (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); + // Sort children of node and save the information needed to + // update the list of persistent indexes. + QVector> sortResult = node->sort(order); QModelIndexList from, to; int columns = columnCount(); - for (const auto &move : mapping) { - const int preSortRow = move.first; - const int finalRow = move.second; - AbstractDecklistNode *temp = node->at(finalRow); + 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 (int j = 0; j < columns; ++j) { - from << createIndex(preSortRow, j, temp); - to << createIndex(finalRow, j, temp); + from << createIndex(fromRow, j, temp); + to << createIndex(toRow, j, temp); } } changePersistentIndexList(from, to); @@ -838,15 +704,6 @@ 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 09600ca67..209ec8c42 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,8 +1,6 @@ #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 @@ -32,8 +30,7 @@ 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. */ - IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ + IsLegalRole /**< Whether the card is legal in the current deck format. */ }; } // namespace DeckRoles @@ -394,14 +391,6 @@ 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. */ @@ -438,7 +427,6 @@ 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 deleted file mode 100644 index 1dc745e63..000000000 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp +++ /dev/null @@ -1,152 +0,0 @@ -#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 deleted file mode 100644 index 518a9e1d2..000000000 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h +++ /dev/null @@ -1,98 +0,0 @@ -#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 fd254a564..5dcf4b23f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -91,6 +91,7 @@ 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 diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp index 4c578b4e4..5b38deb3f 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,5 +1,6 @@ #include "rng_sfmt.h" +#include #include #include #include @@ -10,11 +11,10 @@ #define UINT64_MAX (~(uint64_t)0) #endif -RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent) +RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) { - // 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); + // initialize the random number generator with a 32bit integer seed (timestamp) + sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); } /** diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h index a180dad99..7e9f53df3 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(uint64_t seed, QObject *parent = nullptr); + explicit RNG_SFMT(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 cf5bfd81a..85a1424a6 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -106,11 +106,6 @@ 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"); @@ -155,8 +150,3 @@ 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 eb78d311b..365d91af7 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -43,7 +43,6 @@ 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); @@ -58,7 +57,6 @@ 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 db23f7951..c6411ea76 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -6,15 +6,13 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(UTILITY_SOURCES - 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 + 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 @@ -34,9 +32,7 @@ add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -find_package(OpenSSL REQUIRED) - -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE}) +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) set(ORACLE_LIBS) diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp deleted file mode 100644 index 416ef261b..000000000 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#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 deleted file mode 100644 index dba9dc37d..000000000 --- a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h +++ /dev/null @@ -1,13 +0,0 @@ -#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 1c22fdcfa..c40c5f94f 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,28 +21,12 @@ 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; - ret.reserve(len); - QByteArray random = CryptoUtil::randomBytes(len * 2); - int bytesUsed = 0; + int size = sizeof(alphanum) - 1; + for (int i = 0; i < len; ++i) { - 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]); + ret.append(alphanum[rng->rand(0, size)]); } return ret; @@ -50,5 +34,5 @@ QString PasswordHasher::generateRandomSalt(const int len) QString PasswordHasher::generateActivationToken() { - return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); + return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); } diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 68c4709bb..0736db7f5 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/raw_json_scanner.cpp + src/qt-json/json.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp @@ -113,8 +113,6 @@ 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 d745b250c..fdb32bb8d 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,13 +3,10 @@ #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 @@ -45,29 +42,31 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(QByteArray data) +bool OracleImporter::readSetsFromByteArray(const QByteArray &data) { - RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(data, &error); - if (error.isError()) { - qDebug() << "error: RawJson::scanSetRanges():" << error.message; + bool ok; + auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); + if (!ok) { + qDebug() << "error: QtJson::Json::parse()"; return false; } QList newSetList; - newSetList.reserve(ranges.size()); - 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); + 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(); 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" - static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", - "of", "in", "and", "with", "or"}; + const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -75,15 +74,13 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1) + QString(" "); + setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); first = true; } } setType = setType.trimmed(); } - SetToDownload set(shortName, longName, priority, setType, releaseDate); - set.setRawRange(range.dataRange); - newSetList.append(set); + newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); } std::sort(newSetList.begin(), newSetList.end()); @@ -92,7 +89,6 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) return false; } allSets = newSetList; - rawSetsData = std::move(data); return true; } @@ -126,8 +122,14 @@ 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 - auto last = std::unique(colors.begin(), colors.end()); - colors.erase(last, colors.end()); + 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); + } + } } CardInfoPtr OracleImporter::addCard(QString name, @@ -140,12 +142,9 @@ CardInfoPtr OracleImporter::addCard(QString name, // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - auto existingIt = cards.constFind(name); - if (existingIt != cards.constEnd()) { - CardInfoPtr card = existingIt.value(); + if (cards.contains(name)) { + CardInfoPtr card = cards.value(name); 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); } @@ -183,9 +182,8 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - QString layoutVal = properties.value("layout"); - bool landscapeOrientation = - properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; + bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || + properties.value("layout") == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; @@ -224,15 +222,12 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getJsonString(const QJsonObject &obj, const QString &key) +static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) { - // 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(); + return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -253,7 +248,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -261,16 +256,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QMap, QString>> splitCards; // Keeps track of all names encountered so far - QSet allNameProps; + QList allNameProps; - for (const QJsonValue &cardVal : cardsList) { - QJsonObject card = cardVal.toObject(); + for (const QVariant &cardVar : cardsList) { + QVariantMap card = cardVar.toMap(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getJsonString(card, "layout"); + QString layout = getStringPropertyFromMap(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -278,9 +273,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } // normal cards handling - QString name = getJsonString(card, "name"); - QString text = getJsonString(card, "text"); - QString faceName = getJsonString(card, "faceName"); + QString name = getStringPropertyFromMap(card, "name"); + QString text = getStringPropertyFromMap(card, "text"); + QString faceName = getStringPropertyFromMap(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } @@ -288,34 +283,39 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // card properties QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(card, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - properties.insert(i.value(), propertyValue); + properties.insert(xmlPropertyName, propertyValue); } } // per-set properties QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(card, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingProps.insert(i.value(), propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getJsonString(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName"); + QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(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 propertyValue = getJsonString(identifiers, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingProps.insert(i.value(), propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } @@ -331,26 +331,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.insert(faceName); + allNameProps.append(faceName); // special handling properties - QString colors; - for (const QJsonValue &color : card.value("colors").toArray()) { - colors += color.toString(); - } + QString colors = card.value("colors").toStringList().join(""); if (!colors.isEmpty()) { properties.insert("colors", colors); } - QString colorIdentity; - for (const QJsonValue &color : card.value("colorIdentity").toArray()) { - colorIdentity += color.toString(); - } + // special handling properties + QString colorIdentity = card.value("colorIdentity").toStringList().join(""); if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -359,22 +354,22 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // 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 = getJsonString(card, "power"); - QString toughness = getJsonString(card, "toughness"); + QString power = getStringPropertyFromMap(card, "power"); + QString toughness = getStringPropertyFromMap(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").toObject(); - for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) { + auto legalities = card.value("legalities").toMap(); + for (auto i = legalities.cbegin(), end = legalities.cend(); 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 = getJsonString(card, "faceName"); + auto _faceName = getStringPropertyFromMap(card, "faceName"); SplitCardPart split(_faceName, text, properties, printingInfo); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { @@ -387,11 +382,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QList relatedCards; // add other face for split cards as card relation - if (!getJsonString(card, "side").isEmpty()) { - auto faceManaValue = getJsonString(card, "faceManaValue"); + if (!getStringPropertyFromMap(card, "side").isEmpty()) { + auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getJsonString(card, "faceConvertedManaCost"); + faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -411,21 +406,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson name = faceName; } - // mtgjson related cards - QJsonObject givenRelated = card.value("relatedCards").toObject(); - if (!givenRelated.isEmpty()) { + // mtgjon related cards + if (card.contains("relatedCards")) { + QVariantMap givenRelated = card.value("relatedCards").toMap(); // conjured cards from a spellbook - 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)); + 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)); } } } - CardInfoPtr newCard = - addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); + CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); numCards++; } } @@ -433,6 +427,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // 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) { @@ -458,7 +453,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson 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" || prop == "coloridentity") { // the card is both colors + } else if (prop == "colors") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); } else if (prop == "maintype") { // don't create maintypes with //es in them continue; @@ -470,20 +465,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } } - CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); numCards++; } return numCards; } -static FormatRulesNameMap buildDefaultMagicFormats() +FormatRulesNameMap OracleImporter::createDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; + superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -496,6 +491,7 @@ static FormatRulesNameMap buildDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); + // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -541,27 +537,10 @@ static FormatRulesNameMap buildDefaultMagicFormats() 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"); @@ -573,44 +552,11 @@ 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); } - const QJsonArray setCards = setDoc.object().value("cards").toArray(); - int numCardsInSet = importCardsFromSet(newSet, setCards); + int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); ++setIndex; @@ -630,16 +576,9 @@ 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 8cb30ca40..99644f9ce 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,11 +1,6 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H -#include "raw_json_scanner.h" - -#include -#include -#include #include #include #include @@ -49,12 +44,10 @@ 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 @@ -65,6 +58,10 @@ public: { return longName; } + const QList &getCards() const + { + return cards; + } const QString &getSetType() const { return setType; @@ -77,23 +74,16 @@ 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)), releaseDate(_releaseDate), - setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), + 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; @@ -149,12 +139,6 @@ 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, @@ -167,18 +151,11 @@ signals: public: explicit OracleImporter(QObject *parent = nullptr); - /** - * 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); + bool readSetsFromByteArray(const QByteArray &data); int startImport(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); - 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(); + int importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList); + FormatRulesNameMap createDefaultMagicFormats(); const CardNameMap &getCardList() const { return cards; @@ -187,11 +164,6 @@ 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 94e662ffe..df4d1a98c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -560,9 +560,6 @@ 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 new file mode 100644 index 000000000..29a85929f --- /dev/null +++ b/oracle/src/qt-json/AUTHORS @@ -0,0 +1,3 @@ +Eeli Reilin +Luis Gustavo S. Barreto +Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE new file mode 100644 index 000000000..3c42b515a --- /dev/null +++ b/oracle/src/qt-json/LICENSE @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..b60c1599b --- /dev/null +++ b/oracle/src/qt-json/README @@ -0,0 +1,96 @@ +######################################################################## +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 new file mode 100644 index 000000000..ff739b49d --- /dev/null +++ b/oracle/src/qt-json/json.cpp @@ -0,0 +1,545 @@ +/* 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 new file mode 100644 index 000000000..cf0499d4e --- /dev/null +++ b/oracle/src/qt-json/json.h @@ -0,0 +1,204 @@ +/* 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 deleted file mode 100644 index db972491c..000000000 --- a/oracle/src/raw_json_scanner.cpp +++ /dev/null @@ -1,621 +0,0 @@ -#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 deleted file mode 100644 index f6e3a4647..000000000 --- a/oracle/src/raw_json_scanner.h +++ /dev/null @@ -1,76 +0,0 @@ -#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 5d8089ad1..aba63800c 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -95,8 +95,6 @@ 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 13bf95a82..9e7fe38d9 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -33,7 +33,6 @@ #include #include #include -#include #include RNG_Abstract *rng; @@ -170,7 +169,7 @@ int main(int argc, char *argv[]) signalhandler = new SignalHandler(); - rng = new RNG_SFMT(CryptoUtil::randomUInt64()); + rng = new RNG_SFMT; std::cerr << "Servatrice " << VERSION_STRING << " starting." << std::endl; std::cerr << "-------------------------" << std::endl; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 18ab60d06..34784538b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,7 +114,6 @@ 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 deleted file mode 100644 index e3096c559..000000000 --- a/tests/deck_list_model/CMakeLists.txt +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 7f4ab81e1..000000000 --- a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp +++ /dev/null @@ -1,276 +0,0 @@ -/** - * @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 deleted file mode 100644 index a4562c95d..000000000 --- a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp +++ /dev/null @@ -1,283 +0,0 @@ -#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 801f226a9..a5148621d 100644 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -213,42 +213,6 @@ 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 9bc5ee5be..c5c1e9097 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,63 +7,3 @@ 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 deleted file mode 100644 index 633c9e40a..000000000 --- a/tests/oracle/oracle_importer_benchmark_test.cpp +++ /dev/null @@ -1,578 +0,0 @@ -#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 deleted file mode 100644 index 3834be9ed..000000000 --- a/tests/oracle/oracle_importer_test.cpp +++ /dev/null @@ -1,748 +0,0 @@ -#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 2b8f8bdb7..38d9b6315 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,9 +1,25 @@ #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) { @@ -13,29 +29,6 @@ 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 139656f27..6c79d5227 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -238,12 +238,6 @@ 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)