mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-28 08:52:19 -07:00
Compare commits
1 commit
d18e28a65b
...
7ce7ae0432
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce7ae0432 |
58 changed files with 167 additions and 3591 deletions
|
|
@ -149,9 +149,6 @@ if [[ $MAKE_TEST ]]; then
|
||||||
fi
|
fi
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ $USE_CCACHE ]]; then
|
||||||
flags+=("-DUSE_CCACHE=1")
|
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
|
if [[ $CCACHE_SIZE ]]; then
|
||||||
# note, this setting persists after running the script
|
# note, this setting persists after running the script
|
||||||
ccache --max-size "$CCACHE_SIZE"
|
ccache --max-size "$CCACHE_SIZE"
|
||||||
|
|
|
||||||
8
.github/workflows/desktop-build.yml
vendored
8
.github/workflows/desktop-build.yml
vendored
|
|
@ -176,12 +176,8 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
source .ci/docker.sh
|
source .ci/docker.sh
|
||||||
args=()
|
RUN --server --debug --test --ccache "$CCACHE_SIZE" \
|
||||||
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
|
--cmake-generator "$CMAKE_GENERATOR"
|
||||||
args+=(--ccache "$CCACHE_SIZE")
|
|
||||||
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
|
||||||
|
|
||||||
RUN --server --debug --test "${args[@]}"
|
|
||||||
|
|
||||||
- name: "Build release package"
|
- name: "Build release package"
|
||||||
id: build
|
id: build
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
# Use compiler cache (ccache)
|
# 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)
|
# Treat warnings as errors (Debug builds only)
|
||||||
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
|
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
|
||||||
# Check for translation updates
|
# Check for translation updates
|
||||||
|
|
@ -39,24 +39,13 @@ else()
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# ccache does not support MSVC and must not auto-engage on Windows
|
if(USE_CCACHE)
|
||||||
# (it is installed unintentionally on the Windows CI runner).
|
|
||||||
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
|
|
||||||
# also opts out of ccache even though the GNUCXX branch below supports it.
|
|
||||||
if(USE_CCACHE AND NOT WIN32)
|
|
||||||
find_program(CCACHE_PROGRAM ccache)
|
find_program(CCACHE_PROGRAM ccache)
|
||||||
if(CCACHE_PROGRAM)
|
if(CCACHE_PROGRAM)
|
||||||
# Support Unix Makefiles and Ninja
|
# Support Unix Makefiles and Ninja
|
||||||
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
|
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}")
|
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
|
||||||
endif()
|
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()
|
endif()
|
||||||
|
|
||||||
if(WIN32 OR USE_VCPKG)
|
if(WIN32 OR USE_VCPKG)
|
||||||
|
|
|
||||||
|
|
@ -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 <QBasicTimer>
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QDebug>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QHash>
|
|
||||||
#include <QList>
|
|
||||||
#include <QLoggingCategory>
|
|
||||||
#include <QMap>
|
|
||||||
#include <QMetaObject>
|
|
||||||
#include <QObject>
|
|
||||||
#include <QRandomGenerator>
|
|
||||||
#include <QSharedPointer>
|
|
||||||
#include <QString>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QTimer>
|
|
||||||
#include <QVariant>
|
|
||||||
|
|
@ -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 <QAction>
|
|
||||||
#include <QApplication>
|
|
||||||
#include <QFrame>
|
|
||||||
#include <QGraphicsItem>
|
|
||||||
#include <QGraphicsScene>
|
|
||||||
#include <QGraphicsView>
|
|
||||||
#include <QImage>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QLayout>
|
|
||||||
#include <QMainWindow>
|
|
||||||
#include <QMenu>
|
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QNetworkReply>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QScrollArea>
|
|
||||||
#include <QTabWidget>
|
|
||||||
#include <QToolBar>
|
|
||||||
#include <QTreeWidget>
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
@ -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_editor_printing_selector_dock_widget.cpp
|
||||||
src/interface/widgets/deck_editor/deck_list_style_proxy.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_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/deck_editor/printing_disabled_info_widget.cpp
|
||||||
src/interface/widgets/general/background_sources.cpp
|
src/interface/widgets/general/background_sources.cpp
|
||||||
src/interface/widgets/general/display/background_plate_widget.cpp
|
src/interface/widgets/general/display/background_plate_widget.cpp
|
||||||
|
|
@ -517,8 +516,6 @@ qt6_add_executable(
|
||||||
MANUAL_FINALIZATION
|
MANUAL_FINALIZATION
|
||||||
)
|
)
|
||||||
|
|
||||||
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
|
|
||||||
|
|
||||||
qt6_add_shaders(
|
qt6_add_shaders(
|
||||||
cockatrice
|
cockatrice
|
||||||
"onboarding_shaders"
|
"onboarding_shaders"
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ In this list of examples below, each entry has an explanation and can be clicked
|
||||||
<dt><u>E</u>dition:</dt>
|
<dt><u>E</u>dition:</dt>
|
||||||
<dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd>
|
<dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd>
|
||||||
<dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd>
|
<dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd>
|
||||||
<dd>[e<8ED](#e<8ED) <small>(Cards that appear before 8th edition)</small></dd>
|
|
||||||
|
|
||||||
<dt>Negate:</dt>
|
<dt>Negate:</dt>
|
||||||
<dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd>
|
<dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <libcockatrice/card/card_info.h>
|
#include <libcockatrice/card/card_info.h>
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
|
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||||
#include <libcockatrice/settings/cards_display_settings.h>
|
#include <libcockatrice/settings/cards_display_settings.h>
|
||||||
|
|
||||||
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
|
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
|
||||||
|
|
@ -380,10 +381,12 @@ void DeckViewScene::rebuildTree()
|
||||||
addItem(container);
|
addItem(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards in custom zones nested under a board are regular board cards in-game.
|
for (int j = 0; j < currentZone->size(); j++) {
|
||||||
// They are collected recursively (like every other consumer) and reported with
|
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||||
// the top-level board zone as their origin, so that sideboard plans keep working.
|
if (!currentCard) {
|
||||||
for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) {
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (int k = 0; k < currentCard->getNumber(); ++k) {
|
for (int k = 0; k < currentCard->getNumber(); ++k) {
|
||||||
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
|
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
|
||||||
container->addCard(newCard);
|
container->addCard(newCard);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,11 @@ TallyMenu::TallyMenu()
|
||||||
aTallyNone = createTallyAction(TallyType::None);
|
aTallyNone = createTallyAction(TallyType::None);
|
||||||
aTallySubtypes = createTallyAction(TallyType::Subtypes);
|
aTallySubtypes = createTallyAction(TallyType::Subtypes);
|
||||||
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
|
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
|
||||||
aTallyTotalToughness = createTallyAction(TallyType::TotalToughness);
|
|
||||||
|
|
||||||
addAction(aTallyNone);
|
addAction(aTallyNone);
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aTallySubtypes);
|
addAction(aTallySubtypes);
|
||||||
addAction(aTallyTotalPower);
|
addAction(aTallyTotalPower);
|
||||||
addAction(aTallyTotalToughness);
|
|
||||||
|
|
||||||
retranslateUi();
|
retranslateUi();
|
||||||
}
|
}
|
||||||
|
|
@ -56,5 +54,4 @@ void TallyMenu::retranslateUi()
|
||||||
aTallyNone->setText(tr("None"));
|
aTallyNone->setText(tr("None"));
|
||||||
aTallySubtypes->setText(tr("Subtypes"));
|
aTallySubtypes->setText(tr("Subtypes"));
|
||||||
aTallyTotalPower->setText(tr("Total Power"));
|
aTallyTotalPower->setText(tr("Total Power"));
|
||||||
aTallyTotalToughness->setText(tr("Total Toughness"));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ private:
|
||||||
QAction *aTallyNone = nullptr;
|
QAction *aTallyNone = nullptr;
|
||||||
QAction *aTallySubtypes = nullptr;
|
QAction *aTallySubtypes = nullptr;
|
||||||
QAction *aTallyTotalPower = nullptr;
|
QAction *aTallyTotalPower = nullptr;
|
||||||
QAction *aTallyTotalToughness = nullptr;
|
|
||||||
|
|
||||||
QAction *createTallyAction(TallyType tallyType);
|
QAction *createTallyAction(TallyType tallyType);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,31 +34,3 @@ QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
|
||||||
QString name = QCoreApplication::translate("StatsTally", "Total Power");
|
QString name = QCoreApplication::translate("StatsTally", "Total Power");
|
||||||
return {TallyRow{name, QString::number(total)}};
|
return {TallyRow{name, QString::number(total)}};
|
||||||
}
|
}
|
||||||
|
|
||||||
static int sumToughness(const QList<CardItem *> &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<TallyRow> StatsTally::computeTotalToughness(const QList<CardItem *> &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)}};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,6 @@ namespace StatsTally
|
||||||
*/
|
*/
|
||||||
QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards);
|
QList<TallyRow> computeTotalPower(const QList<CardItem *> &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<TallyRow> computeTotalToughness(const QList<CardItem *> &cards);
|
|
||||||
|
|
||||||
} // namespace StatsTally
|
} // namespace StatsTally
|
||||||
|
|
||||||
#endif // COCKATRICE_STATS_TALLY_H
|
#endif // COCKATRICE_STATS_TALLY_H
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ QList<TallyRow> Tally::compute(const QList<CardItem *> &cards, const TallyType t
|
||||||
return SubtypeTally::countSubtypes(cards);
|
return SubtypeTally::countSubtypes(cards);
|
||||||
case TallyType::TotalPower:
|
case TallyType::TotalPower:
|
||||||
return StatsTally::computeTotalPower(cards);
|
return StatsTally::computeTotalPower(cards);
|
||||||
case TallyType::TotalToughness:
|
|
||||||
return StatsTally::computeTotalToughness(cards);
|
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ enum class TallyType
|
||||||
None,
|
None,
|
||||||
Subtypes,
|
Subtypes,
|
||||||
TotalPower,
|
TotalPower,
|
||||||
TotalToughness,
|
MaxValue = TotalPower // sentinel value
|
||||||
MaxValue = TotalToughness // sentinel value
|
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace Tally
|
namespace Tally
|
||||||
|
|
|
||||||
|
|
@ -375,32 +375,15 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL
|
||||||
void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
||||||
const InnerDecklistNode *zoneNode,
|
const InnerDecklistNode *zoneNode,
|
||||||
bool addComments,
|
bool addComments,
|
||||||
bool addSetNameAndNumber,
|
bool addSetNameAndNumber)
|
||||||
const QString &boardZoneName)
|
|
||||||
{
|
{
|
||||||
// Nested sub-zones keep their owning board's identity: the top-level call
|
|
||||||
// passes no board, so the zone's own name is used; recursive calls carry the
|
|
||||||
// owning board down so the sideboard marker survives sub-zone nesting.
|
|
||||||
const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName;
|
|
||||||
|
|
||||||
// group cards by card type and count the subtotals
|
// group cards by card type and count the subtotals
|
||||||
QMultiMap<QString, DecklistCardNode *> cardsByType;
|
QMultiMap<QString, DecklistCardNode *> cardsByType;
|
||||||
QMap<QString, int> cardTotalByType;
|
QMap<QString, int> cardTotalByType;
|
||||||
int cardTotal = 0;
|
int cardTotal = 0;
|
||||||
QList<const InnerDecklistNode *> subZones;
|
|
||||||
|
|
||||||
for (int j = 0; j < zoneNode->size(); j++) {
|
for (int j = 0; j < zoneNode->size(); j++) {
|
||||||
auto *card = dynamic_cast<DecklistCardNode *>(zoneNode->at(j));
|
auto *card = dynamic_cast<DecklistCardNode *>(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<const InnerDecklistNode *>(zoneNode->at(j))) {
|
|
||||||
subZones.append(subZone);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName());
|
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName());
|
||||||
QString cardType = info ? info->getMainCardType() : "unknown";
|
QString cardType = info ? info->getMainCardType() : "unknown";
|
||||||
|
|
@ -428,30 +411,25 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
||||||
|
|
||||||
QList<DecklistCardNode *> cards = cardsByType.values(cardType);
|
QList<DecklistCardNode *> cards = cardsByType.values(cardType);
|
||||||
|
|
||||||
saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName);
|
saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber);
|
||||||
|
|
||||||
if (addComments) {
|
if (addComments) {
|
||||||
out << "\n";
|
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,
|
void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out,
|
||||||
|
const InnerDecklistNode *zoneNode,
|
||||||
QList<DecklistCardNode *> cards,
|
QList<DecklistCardNode *> cards,
|
||||||
bool addComments,
|
bool addComments,
|
||||||
bool addSetNameAndNumber,
|
bool addSetNameAndNumber)
|
||||||
const QString &boardZoneName)
|
|
||||||
{
|
{
|
||||||
// QMultiMap sorts values in reverse order
|
// QMultiMap sorts values in reverse order
|
||||||
for (int i = cards.size() - 1; i >= 0; --i) {
|
for (int i = cards.size() - 1; i >= 0; --i) {
|
||||||
DecklistCardNode *card = cards[i];
|
DecklistCardNode *card = cards[i];
|
||||||
|
|
||||||
if (boardZoneName == DECK_ZONE_SIDE && addComments) {
|
if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) {
|
||||||
out << "SB: ";
|
out << "SB: ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,26 +510,9 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck)
|
||||||
|
|
||||||
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
|
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
|
||||||
{
|
{
|
||||||
if (!node || node->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int totalColumns = 2;
|
const int totalColumns = 2;
|
||||||
|
|
||||||
// Dispatch children by type instead of trusting a whole-node height: a deck
|
if (node->height() == 1) {
|
||||||
// node may hold direct cards and nested zones side by side (custom zones),
|
|
||||||
// and an empty node would previously crash on at(0).
|
|
||||||
QVector<const AbstractDecklistCardNode *> cards;
|
|
||||||
QVector<const InnerDecklistNode *> subZones;
|
|
||||||
for (int i = 0; i < node->size(); i++) {
|
|
||||||
if (auto *card = dynamic_cast<const AbstractDecklistCardNode *>(node->at(i))) {
|
|
||||||
cards.append(card);
|
|
||||||
} else if (auto *zone = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
|
|
||||||
subZones.append(zone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cards.isEmpty()) {
|
|
||||||
QTextBlockFormat blockFormat;
|
QTextBlockFormat blockFormat;
|
||||||
QTextCharFormat charFormat;
|
QTextCharFormat charFormat;
|
||||||
charFormat.setFontPointSize(11);
|
charFormat.setFontPointSize(11);
|
||||||
|
|
@ -562,9 +523,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
||||||
tableFormat.setCellPadding(0);
|
tableFormat.setCellPadding(0);
|
||||||
tableFormat.setCellSpacing(0);
|
tableFormat.setCellSpacing(0);
|
||||||
tableFormat.setBorder(0);
|
tableFormat.setBorder(0);
|
||||||
QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat);
|
QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat);
|
||||||
for (int i = 0; i < cards.size(); i++) {
|
for (int i = 0; i < node->size(); i++) {
|
||||||
const AbstractDecklistCardNode *card = cards[i];
|
auto *card = dynamic_cast<AbstractDecklistCardNode *>(node->at(i));
|
||||||
|
|
||||||
QTextCharFormat cellCharFormat;
|
QTextCharFormat cellCharFormat;
|
||||||
cellCharFormat.setFontPointSize(9);
|
cellCharFormat.setFontPointSize(9);
|
||||||
|
|
@ -579,13 +540,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
||||||
cellCursor = cell.firstCursorPosition();
|
cellCursor = cell.firstCursorPosition();
|
||||||
cellCursor.insertText(card->getName());
|
cellCursor.insertText(card->getName());
|
||||||
}
|
}
|
||||||
}
|
} else if (node->height() == 2) {
|
||||||
|
|
||||||
for (const InnerDecklistNode *subZone : subZones) {
|
|
||||||
if (subZone->isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
QTextBlockFormat blockFormat;
|
QTextBlockFormat blockFormat;
|
||||||
QTextCharFormat charFormat;
|
QTextCharFormat charFormat;
|
||||||
charFormat.setFontPointSize(14);
|
charFormat.setFontPointSize(14);
|
||||||
|
|
@ -604,8 +559,10 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
||||||
tableFormat.setColumnWidthConstraints(constraints);
|
tableFormat.setColumnWidthConstraints(constraints);
|
||||||
|
|
||||||
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
|
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
|
||||||
QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition();
|
for (int i = 0; i < node->size(); i++) {
|
||||||
printDeckListNode(&cellCursor, subZone);
|
QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition();
|
||||||
|
printDeckListNode(&cellCursor, dynamic_cast<InnerDecklistNode *>(node->at(i)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor->movePosition(QTextCursor::End);
|
cursor->movePosition(QTextCursor::End);
|
||||||
|
|
|
||||||
|
|
@ -159,13 +159,12 @@ private:
|
||||||
static void saveToStream_DeckZone(QTextStream &out,
|
static void saveToStream_DeckZone(QTextStream &out,
|
||||||
const InnerDecklistNode *zoneNode,
|
const InnerDecklistNode *zoneNode,
|
||||||
bool addComments = true,
|
bool addComments = true,
|
||||||
bool addSetNameAndNumber = true,
|
bool addSetNameAndNumber = true);
|
||||||
const QString &boardZoneName = QString());
|
|
||||||
static void saveToStream_DeckZoneCards(QTextStream &out,
|
static void saveToStream_DeckZoneCards(QTextStream &out,
|
||||||
|
const InnerDecklistNode *zoneNode,
|
||||||
QList<DecklistCardNode *> cards,
|
QList<DecklistCardNode *> cards,
|
||||||
bool addComments = true,
|
bool addComments = true,
|
||||||
bool addSetNameAndNumber = true,
|
bool addSetNameAndNumber = true);
|
||||||
const QString &boardZoneName = QString());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -174,18 +174,16 @@ void CardGroupDisplayWidget::updateCardDisplays()
|
||||||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||||
|
|
||||||
// 4. persist the source index
|
// 4. persist the source index
|
||||||
addCardWidgets(QPersistentModelIndex(sourceIndex));
|
QPersistentModelIndex persistent(sourceIndex);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent)
|
// Get the card amount
|
||||||
{
|
int cardAmount =
|
||||||
// Get the card amount
|
sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||||
int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
|
||||||
|
|
||||||
// Create multiple widgets for the card count
|
// Create multiple widgets for the card count
|
||||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||||
addToLayout(constructWidgetForIndex(persistent));
|
addToLayout(constructWidgetForIndex(persistent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ public:
|
||||||
void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
|
void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
|
||||||
void refreshSelectionForIndex(const QPersistentModelIndex &persistent);
|
void refreshSelectionForIndex(const QPersistentModelIndex &persistent);
|
||||||
void clearAllDisplayWidgets();
|
void clearAllDisplayWidgets();
|
||||||
void addCardWidgets(const QPersistentModelIndex &persistent);
|
|
||||||
|
|
||||||
DeckListModel *deckListModel;
|
DeckListModel *deckListModel;
|
||||||
QItemSelectionModel *selectionModel;
|
QItemSelectionModel *selectionModel;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
#include "libcockatrice/card/database/card_database_manager.h"
|
#include "libcockatrice/card/database/card_database_manager.h"
|
||||||
|
|
||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
#include <algorithm>
|
|
||||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||||
|
|
||||||
DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
||||||
|
|
@ -52,6 +51,11 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
||||||
// User Interaction
|
// User Interaction
|
||||||
// =====================================================================================================================
|
// =====================================================================================================================
|
||||||
|
|
||||||
|
void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
|
||||||
|
{
|
||||||
|
emit cardClicked(event, card, zoneName);
|
||||||
|
}
|
||||||
|
|
||||||
void DeckCardZoneDisplayWidget::onHover(const ExactCard &card)
|
void DeckCardZoneDisplayWidget::onHover(const ExactCard &card)
|
||||||
{
|
{
|
||||||
emit cardHovered(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();
|
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) {
|
if (displayType == DisplayType::Overlap) {
|
||||||
auto *displayWidget = new OverlappedCardGroupDisplayWidget(
|
auto *displayWidget = new OverlappedCardGroupDisplayWidget(
|
||||||
cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName,
|
cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria,
|
||||||
activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this,
|
||||||
|
&DeckCardZoneDisplayWidget::onClick);
|
||||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this,
|
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this,
|
||||||
&DeckCardZoneDisplayWidget::onHover);
|
&DeckCardZoneDisplayWidget::onHover);
|
||||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||||
|
|
@ -113,9 +111,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
||||||
indexToWidgetMap.insert(index, displayWidget);
|
indexToWidgetMap.insert(index, displayWidget);
|
||||||
} else if (displayType == DisplayType::Flat) {
|
} else if (displayType == DisplayType::Flat) {
|
||||||
auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index,
|
auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index,
|
||||||
effectiveZoneName, categoryName, activeGroupCriteria,
|
zoneName, categoryName, activeGroupCriteria,
|
||||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
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, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover);
|
||||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||||
&DeckCardZoneDisplayWidget::cleanupInvalidCardGroup);
|
&DeckCardZoneDisplayWidget::cleanupInvalidCardGroup);
|
||||||
|
|
@ -128,18 +126,24 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
||||||
|
|
||||||
void DeckCardZoneDisplayWidget::displayCards()
|
void DeckCardZoneDisplayWidget::displayCards()
|
||||||
{
|
{
|
||||||
if (!trackedIndex.isValid()) {
|
QSortFilterProxyModel proxy;
|
||||||
return;
|
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
|
// 1. trackedIndex is a source index → map it to proxy space
|
||||||
// order (criteria groups first, then custom zones, both in the model's sort order).
|
QModelIndex proxyParent = proxy.mapFromSource(trackedIndex);
|
||||||
QList<QPersistentModelIndex> rows;
|
|
||||||
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
|
// 2. iterate children under the proxy parent
|
||||||
rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex)));
|
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);
|
constructAppropriateWidget(persistent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ public:
|
||||||
void addCardsToOverlapWidget();
|
void addCardsToOverlapWidget();
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
|
void onClick(QMouseEvent *event, const ExactCard &card);
|
||||||
void onHover(const ExactCard &card);
|
void onHover(const ExactCard &card);
|
||||||
void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget);
|
void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget);
|
||||||
void constructAppropriateWidget(QPersistentModelIndex index);
|
void constructAppropriateWidget(QPersistentModelIndex index);
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,6 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
|
||||||
emit cardDecremented(currentCardName(), zoneName);
|
emit cardDecremented(currentCardName(), zoneName);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabaseView::setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
|
||||||
const std::function<QString()> &newZoneHandler)
|
|
||||||
{
|
|
||||||
zoneMenuProvider = provider;
|
|
||||||
this->newZoneHandler = newZoneHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/)
|
void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/)
|
||||||
{
|
{
|
||||||
if (!current.isValid()) {
|
if (!current.isValid()) {
|
||||||
|
|
@ -149,50 +142,6 @@ void CardDatabaseView::openCustomMenu(QPoint point)
|
||||||
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
|
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
|
||||||
connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked);
|
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)) {
|
if (canBeCommander(*card)) {
|
||||||
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
|
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
|
||||||
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });
|
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
#include "../../key_signals.h"
|
#include "../../key_signals.h"
|
||||||
|
|
||||||
#include <QTreeView>
|
#include <QTreeView>
|
||||||
#include <functional>
|
|
||||||
#include <libcockatrice/card/card_info.h>
|
#include <libcockatrice/card/card_info.h>
|
||||||
|
|
||||||
class CardDatabaseModel;
|
class CardDatabaseModel;
|
||||||
|
|
@ -20,13 +19,6 @@ class CardDatabaseView : public QTreeView
|
||||||
KeySignals searchKeySignals;
|
KeySignals searchKeySignals;
|
||||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
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<QList<QPair<QString, QStringList>>()> 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<QString()> newZoneHandler;
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model);
|
explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model);
|
||||||
|
|
||||||
|
|
@ -41,17 +33,6 @@ public:
|
||||||
return &searchKeySignals;
|
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<QList<QPair<QString, QStringList>>()> &provider,
|
|
||||||
const std::function<QString()> &newZoneHandler);
|
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void cardChanged(const QString &cardName);
|
void cardChanged(const QString &cardName);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
#include "deck_editor_card_database_dock_widget.h"
|
#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 <libcockatrice/deck_list/deck_list_node_tree.h>
|
|
||||||
|
|
||||||
DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent)
|
DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent)
|
||||||
{
|
{
|
||||||
setObjectName("databaseDisplayDock");
|
setObjectName("databaseDisplayDock");
|
||||||
|
|
@ -22,27 +15,6 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck
|
||||||
{
|
{
|
||||||
databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel);
|
databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel);
|
||||||
|
|
||||||
databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider(
|
|
||||||
[deckEditor]() -> QList<QPair<QString, QStringList>> {
|
|
||||||
QList<QPair<QString, QStringList>> 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;
|
auto *frame = new QVBoxLayout;
|
||||||
frame->setObjectName("databaseDisplayFrame");
|
frame->setObjectName("databaseDisplayFrame");
|
||||||
frame->addWidget(databaseDisplayWidget);
|
frame->addWidget(databaseDisplayWidget);
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,15 @@
|
||||||
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
|
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
|
||||||
#include "deck_list_style_proxy.h"
|
#include "deck_list_style_proxy.h"
|
||||||
#include "deck_state_manager.h"
|
#include "deck_state_manager.h"
|
||||||
#include "deck_zone_dialog.h"
|
|
||||||
|
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QDockWidget>
|
#include <QDockWidget>
|
||||||
#include <QHeaderView>
|
#include <QHeaderView>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QMessageBox>
|
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
#include <QTextEdit>
|
#include <QTextEdit>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
|
||||||
#include <libcockatrice/settings/deck_editor_settings.h>
|
#include <libcockatrice/settings/deck_editor_settings.h>
|
||||||
#include <libcockatrice/settings/interface_settings.h>
|
#include <libcockatrice/settings/interface_settings.h>
|
||||||
#include <libcockatrice/utility/macros.h>
|
#include <libcockatrice/utility/macros.h>
|
||||||
|
|
@ -775,213 +772,14 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i
|
||||||
|
|
||||||
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
|
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
|
||||||
{
|
{
|
||||||
const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point));
|
|
||||||
|
|
||||||
QMenu menu;
|
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"));
|
QAction *selectPrinting = menu.addAction(tr("Select Printing"));
|
||||||
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
|
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
|
||||||
|
|
||||||
menu.exec(deckView->mapToGlobal(point));
|
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()
|
void DeckEditorDeckDockWidget::refreshShortcuts()
|
||||||
{
|
{
|
||||||
ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts();
|
ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts();
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QDockWidget>
|
#include <QDockWidget>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QMenu>
|
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QTextEdit>
|
#include <QTextEdit>
|
||||||
#include <QTreeView>
|
#include <QTreeView>
|
||||||
|
|
@ -103,11 +102,6 @@ private:
|
||||||
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
|
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
|
||||||
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
|
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:
|
private slots:
|
||||||
void decklistCustomMenu(QPoint point);
|
void decklistCustomMenu(QPoint point);
|
||||||
void updateCard(QModelIndex, const QModelIndex ¤t);
|
void updateCard(QModelIndex, const QModelIndex ¤t);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
#include <libcockatrice/deck_list/deck_list_history_manager.h>
|
#include <libcockatrice/deck_list/deck_list_history_manager.h>
|
||||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
|
||||||
|
|
||||||
DeckStateManager::DeckStateManager(QObject *parent)
|
DeckStateManager::DeckStateManager(QObject *parent)
|
||||||
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
|
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
|
||||||
|
|
@ -308,170 +307,6 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
|
||||||
return offsetCountAtIndex(idx, -1);
|
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)
|
bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
|
||||||
{
|
{
|
||||||
if (!idx.isValid()) {
|
if (!idx.isValid()) {
|
||||||
|
|
@ -532,25 +367,6 @@ void DeckStateManager::requestHistorySave(const QString &reason)
|
||||||
historyManager->save(deckList->createMemento(reason));
|
historyManager->save(deckList->createMemento(reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &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
|
* @brief Handles updating state and emitting signals whenever the cards are modified
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
#include "deck_list_model.h"
|
#include "deck_list_model.h"
|
||||||
|
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <functional>
|
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
|
|
||||||
class DeckListHistoryManager;
|
class DeckListHistoryManager;
|
||||||
|
|
@ -237,68 +236,6 @@ public:
|
||||||
*/
|
*/
|
||||||
bool decrementCountAtIndex(const QModelIndex &idx);
|
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.
|
* 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.
|
* @param steps Number of steps to undo.
|
||||||
|
|
@ -320,7 +257,6 @@ public slots:
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
|
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
|
||||||
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
|
|
||||||
void doCardModified();
|
void doCardModified();
|
||||||
void doMetadataModified();
|
void doMetadataModified();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
#include "deck_zone_dialog.h"
|
|
||||||
|
|
||||||
#include <QComboBox>
|
|
||||||
#include <QDialogButtonBox>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QLineEdit>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
|
||||||
#include <libcockatrice/utility/string_limits.h>
|
|
||||||
|
|
||||||
DeckZoneDialog::DeckZoneDialog(QWidget *parent,
|
|
||||||
const QString &initialBoardName,
|
|
||||||
const std::function<QString(const QString &)> &_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<QString(const QString &)> &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<QString(const QString &)> &nameValidator)
|
|
||||||
{
|
|
||||||
DeckZoneDialog dialog(parent, {}, nameValidator, false);
|
|
||||||
dialog.setZoneName(currentZoneName);
|
|
||||||
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
|
|
||||||
}
|
|
||||||
|
|
@ -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 <QDialog>
|
|
||||||
#include <QEvent>
|
|
||||||
#include <QString>
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
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<QString(const QString &)> &_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<QString(const QString &)> &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<QString(const QString &)> &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<QString(const QString &)> nameValidator;
|
|
||||||
bool allowBoardSelection;
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // DECK_ZONE_DIALOG_H
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
#include "../../../../client/settings/shortcuts_settings.h"
|
#include "../../../../client/settings/shortcuts_settings.h"
|
||||||
#include "../../cards/card_info_display_widget.h"
|
#include "../../cards/card_info_display_widget.h"
|
||||||
#include "../../deck_editor/deck_state_manager.h"
|
#include "../../deck_editor/deck_state_manager.h"
|
||||||
#include "../../deck_editor/deck_zone_dialog.h"
|
|
||||||
#include "../../filters/filter_builder.h"
|
#include "../../filters/filter_builder.h"
|
||||||
#include "../../interface/pixel_map_generator.h"
|
#include "../../interface/pixel_map_generator.h"
|
||||||
#include "../../interface/widgets/cards/card_info_frame_widget.h"
|
#include "../../interface/widgets/cards/card_info_frame_widget.h"
|
||||||
|
|
@ -85,7 +84,6 @@ void TabDeckEditorVisual::createCentralFrame()
|
||||||
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
|
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
|
||||||
&TabDeckEditorVisual::showPrintingSelector);
|
&TabDeckEditorVisual::showPrintingSelector);
|
||||||
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
|
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
|
||||||
tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); });
|
|
||||||
|
|
||||||
centralFrame->addWidget(tabContainer);
|
centralFrame->addWidget(tabContainer);
|
||||||
setCentralWidget(centralWidget);
|
setCentralWidget(centralWidget);
|
||||||
|
|
@ -271,19 +269,6 @@ bool TabDeckEditorVisual::actSaveDeckAs()
|
||||||
return result;
|
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. */
|
/** @brief Refreshes keyboard shortcuts for this tab from settings. */
|
||||||
void TabDeckEditorVisual::refreshShortcuts()
|
void TabDeckEditorVisual::refreshShortcuts()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -165,12 +165,6 @@ public slots:
|
||||||
*/
|
*/
|
||||||
bool actSaveDeckAs() override;
|
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:
|
private:
|
||||||
/**
|
/**
|
||||||
* @brief Sets the deck for this tab and selects the sub-tab to open on
|
* @brief Sets the deck for this tab and selects the sub-tab to open on
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
#include <libcockatrice/card/card_info_comparator.h>
|
#include <libcockatrice/card/card_info_comparator.h>
|
||||||
#include <libcockatrice/card/database/card_database.h>
|
#include <libcockatrice/card/database/card_database.h>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
|
||||||
#include <libcockatrice/settings/cards_display_settings.h>
|
#include <libcockatrice/settings/cards_display_settings.h>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
|
@ -90,19 +89,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
||||||
databaseView->setItemDelegate(nullptr);
|
databaseView->setItemDelegate(nullptr);
|
||||||
databaseView->setVisible(false);
|
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<QPair<QString, QStringList>> {
|
|
||||||
QList<QPair<QString, QStringList>> result;
|
|
||||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
|
||||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
[this] { return newZoneCreator ? newZoneCreator() : QString(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
searchEdit->setTreeView(databaseView);
|
searchEdit->setTreeView(databaseView);
|
||||||
searchEdit->installEventFilter(databaseView->getKeySignals());
|
searchEdit->installEventFilter(databaseView->getKeySignals());
|
||||||
|
|
||||||
|
|
@ -209,11 +195,6 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event)
|
||||||
initializeFilters();
|
initializeFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function<QString()> &creator)
|
|
||||||
{
|
|
||||||
newZoneCreator = creator;
|
|
||||||
}
|
|
||||||
|
|
||||||
void VisualDatabaseDisplayWidget::retranslateUi()
|
void VisualDatabaseDisplayWidget::retranslateUi()
|
||||||
{
|
{
|
||||||
databaseLoadIndicator->setText(tr("Loading database ..."));
|
databaseLoadIndicator->setText(tr("Loading database ..."));
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWheelEvent>
|
#include <QWheelEvent>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <functional>
|
|
||||||
#include <libcockatrice/models/database/card_database_model.h>
|
#include <libcockatrice/models/database/card_database_model.h>
|
||||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||||
#include <qscrollarea.h>
|
#include <qscrollarea.h>
|
||||||
|
|
@ -47,12 +46,6 @@ public:
|
||||||
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
|
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
|
||||||
void setDeckList(const DeckList &new_deck_list_model);
|
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<QString()> &creator);
|
|
||||||
|
|
||||||
CardDatabaseDisplayModel *getDatabaseDisplayModel()
|
CardDatabaseDisplayModel *getDatabaseDisplayModel()
|
||||||
{
|
{
|
||||||
return databaseDisplayModel;
|
return databaseDisplayModel;
|
||||||
|
|
@ -113,7 +106,6 @@ private:
|
||||||
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
|
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
|
||||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
CardDatabaseDisplayModel *databaseDisplayModel;
|
||||||
CardDatabaseView *databaseView;
|
CardDatabaseView *databaseView;
|
||||||
std::function<QString()> newZoneCreator;
|
|
||||||
QList<ExactCard> *cards;
|
QList<ExactCard> *cards;
|
||||||
QVBoxLayout *mainLayout;
|
QVBoxLayout *mainLayout;
|
||||||
QScrollArea *scrollArea;
|
QScrollArea *scrollArea;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ include=("cockatrice/src" \
|
||||||
libcockatrice_* \
|
libcockatrice_* \
|
||||||
"oracle/src" \
|
"oracle/src" \
|
||||||
"servatrice/src" \
|
"servatrice/src" \
|
||||||
"cmake/pch" \
|
|
||||||
"tests")
|
"tests")
|
||||||
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
|
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
|
||||||
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
|
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
|
||||||
|
|
|
||||||
|
|
@ -115,25 +115,6 @@ public:
|
||||||
*/
|
*/
|
||||||
QList<const InnerDecklistNode *> getCustomZones(const QString &boardZoneName) const;
|
QList<const InnerDecklistNode *> 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.
|
* @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 *getZoneObjFromName(const QString &zoneName) const;
|
||||||
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
|
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
|
||||||
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
|
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
|
||||||
|
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
|
||||||
|
bool hasZoneName(const QString &zoneName) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
|
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,6 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const QList<QString> &InnerDecklistNode::boardZoneNames()
|
|
||||||
{
|
|
||||||
static const QList<QString> names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
|
|
||||||
QString(DECK_ZONE_MAYBEBOARD)};
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString InnerDecklistNode::getVisibleName() const
|
QString InnerDecklistNode::getVisibleName() const
|
||||||
{
|
{
|
||||||
return visibleNameFromName(name);
|
return visibleNameFromName(name);
|
||||||
|
|
@ -94,9 +87,6 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber(
|
||||||
|
|
||||||
int InnerDecklistNode::height() const
|
int InnerDecklistNode::height() const
|
||||||
{
|
{
|
||||||
if (isEmpty()) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return at(0)->height() + 1;
|
return at(0)->height() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@
|
||||||
|
|
||||||
#include "abstract_deck_list_node.h"
|
#include "abstract_deck_list_node.h"
|
||||||
|
|
||||||
#include <QList>
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
/** @brief Constant for the "main" deck zone name. */
|
/** @brief Constant for the "main" deck zone name. */
|
||||||
#define DECK_ZONE_MAIN "main"
|
#define DECK_ZONE_MAIN "main"
|
||||||
/** @brief Constant for the "sideboard" zone name. */
|
/** @brief Constant for the "sideboard" zone name. */
|
||||||
|
|
@ -121,13 +118,6 @@ public:
|
||||||
*/
|
*/
|
||||||
static QString visibleNameFromName(const QString &_name);
|
static QString visibleNameFromName(const QString &_name);
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief The standard board zone names, in display order.
|
|
||||||
*
|
|
||||||
* @return main, side and maybeboard.
|
|
||||||
*/
|
|
||||||
static const QList<QString> &boardZoneNames();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get this node’s display-friendly name.
|
* @brief Get this node’s display-friendly name.
|
||||||
* @return Human-readable name (zone/group name).
|
* @return Human-readable name (zone/group name).
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
|
||||||
#include <libcockatrice/utility/peglib.h>
|
#include <libcockatrice/utility/peglib.h>
|
||||||
|
|
||||||
static peg::parser search(R"(
|
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
|
QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery
|
||||||
|
|
||||||
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
|
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
|
||||||
SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
|
SetQuery <- ('e'/'set') [:] FlexStringValue
|
||||||
OracleQuery <- 'o' [:] MatcherString
|
OracleQuery <- 'o' [:] MatcherString
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,8 +64,6 @@ RegexMatcherString <- ('\\/' / !'/' .)+
|
||||||
FlexStringValue <- CompactStringSet / String / [(] StringList [)]
|
FlexStringValue <- CompactStringSet / String / [(] StringList [)]
|
||||||
CompactStringSet <- StringListString ([,+] StringListString)+
|
CompactStringSet <- StringListString ([,+] StringListString)+
|
||||||
|
|
||||||
SetExpression <- NumericOperator ws? String
|
|
||||||
|
|
||||||
NumericExpression <- NumericOperator ws? NumericValue
|
NumericExpression <- NumericOperator ws? NumericValue
|
||||||
NumericOperator <- [=:] / <[><!][=]?>
|
NumericOperator <- [=:] / <[><!][=]?>
|
||||||
NumericValue <- [0-9]+
|
NumericValue <- [0-9]+
|
||||||
|
|
@ -104,25 +101,12 @@ static void setupParserRules()
|
||||||
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
|
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
|
||||||
};
|
};
|
||||||
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
|
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
|
||||||
if (sv.choice() == 1) {
|
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||||
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
|
||||||
return [=](const CardData &x) -> bool {
|
|
||||||
QList<QString> 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<NumberMatcher>(sv[0]);
|
|
||||||
return [=](const CardData &x) -> bool {
|
return [=](const CardData &x) -> bool {
|
||||||
const auto &sets = x->getSets().values();
|
QList<QString> sets = x->getSets().keys();
|
||||||
auto matchesSet = [&](const PrintingInfo &printing) {
|
|
||||||
return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay());
|
auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
|
||||||
};
|
return std::any_of(sets.begin(), sets.end(), matchesSet);
|
||||||
return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) {
|
|
||||||
return std::any_of(printings.begin(), printings.end(), matchesSet);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
search["Rarity"] = [](const peg::SemanticValues &sv) -> QString {
|
search["Rarity"] = [](const peg::SemanticValues &sv) -> QString {
|
||||||
|
|
@ -263,54 +247,40 @@ static void setupParserRules()
|
||||||
return QString::fromStdString(std::string(sv.sv()));
|
return QString::fromStdString(std::string(sv.sv()));
|
||||||
};
|
};
|
||||||
|
|
||||||
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer {
|
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||||
const auto op = QString::fromStdString(std::string(sv.sv()));
|
const auto arg = std::any_cast<int>(sv[1]);
|
||||||
|
const auto op = std::any_cast<QString>(sv[0]);
|
||||||
|
|
||||||
if (op == ">") {
|
if (op == ">") {
|
||||||
return [=](const int s, const int arg) { return s > arg; };
|
return [=](const int s) { return s > arg; };
|
||||||
}
|
}
|
||||||
if (op == ">=") {
|
if (op == ">=") {
|
||||||
return [=](const int s, const int arg) { return s >= arg; };
|
return [=](const int s) { return s >= arg; };
|
||||||
}
|
}
|
||||||
if (op == "<") {
|
if (op == "<") {
|
||||||
return [=](const int s, const int arg) { return s < arg; };
|
return [=](const int s) { return s < arg; };
|
||||||
}
|
}
|
||||||
if (op == "<=") {
|
if (op == "<=") {
|
||||||
return [=](const int s, const int arg) { return s <= arg; };
|
return [=](const int s) { return s <= arg; };
|
||||||
}
|
}
|
||||||
if (op == "=") {
|
if (op == "=") {
|
||||||
return [=](const int s, const int arg) { return s == arg; };
|
return [=](const int s) { return s == arg; };
|
||||||
}
|
}
|
||||||
if (op == ":") {
|
if (op == ":") {
|
||||||
return [=](const int s, const int arg) { return s == arg; };
|
return [=](const int s) { return s == arg; };
|
||||||
}
|
}
|
||||||
if (op == "!=") {
|
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 {
|
search["NumericValue"] = [](const peg::SemanticValues &sv) -> int {
|
||||||
return QString::fromStdString(std::string(sv.sv())).toInt();
|
return QString::fromStdString(std::string(sv.sv())).toInt();
|
||||||
};
|
};
|
||||||
|
|
||||||
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString {
|
||||||
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
|
return QString::fromStdString(std::string(sv.sv()));
|
||||||
const auto arg = std::any_cast<int>(sv[1]);
|
|
||||||
return [=](int s) { return comparer(s, arg); };
|
|
||||||
};
|
|
||||||
|
|
||||||
search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
|
||||||
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
|
|
||||||
const auto setCode = std::any_cast<QString>(sv[1]);
|
|
||||||
const auto allSets = CardDatabaseManager::getInstance()->getSetList();
|
|
||||||
for (auto &set : allSets) {
|
|
||||||
if (set->getShortName() == setCode) {
|
|
||||||
const int releaseDate = set->getReleaseDate().toJulianDay();
|
|
||||||
return [=](int s) { return comparer(s, releaseDate); };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [](int) { return false; };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher {
|
search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ typedef CardInfoPtr CardData;
|
||||||
typedef std::function<bool(const CardData &)> Filter;
|
typedef std::function<bool(const CardData &)> Filter;
|
||||||
typedef std::function<bool(const QString &)> StringMatcher;
|
typedef std::function<bool(const QString &)> StringMatcher;
|
||||||
typedef std::function<bool(int)> NumberMatcher;
|
typedef std::function<bool(int)> NumberMatcher;
|
||||||
typedef std::function<bool(int, int)> NumberComparer;
|
|
||||||
|
|
||||||
namespace peg
|
namespace peg
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
|
||||||
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
|
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
|
||||||
|
|
||||||
add_library(
|
add_library(
|
||||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp
|
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp
|
||||||
deck_list_sort_filter_proxy_model.cpp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,7 @@ void DeckListModel::rebuildTree()
|
||||||
for (int j = 0; j < currentZone->size(); j++) {
|
for (int j = 0; j < currentZone->size(); j++) {
|
||||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||||
|
|
||||||
// Non-card children are custom zones; they are mirrored in a single
|
//! \todo Better sanity checking.
|
||||||
// pass below so each is mirrored exactly once.
|
|
||||||
if (currentCard == nullptr) {
|
if (currentCard == nullptr) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -83,19 +82,8 @@ void DeckListModel::rebuildTree()
|
||||||
|
|
||||||
new DecklistModelCardNode(currentCard, groupNode);
|
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();
|
endResetModel();
|
||||||
|
|
||||||
refreshCardFormatLegalities();
|
refreshCardFormatLegalities();
|
||||||
|
|
@ -166,9 +154,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
||||||
case DeckRoles::IsLegalRole:
|
case DeckRoles::IsLegalRole:
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case DeckRoles::IsCustomZoneRole:
|
|
||||||
return DeckListModelCustomZones::isCustomZone(group);
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
@ -205,10 +190,6 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
||||||
return card->getFormatLegality();
|
return card->getFormatLegality();
|
||||||
}
|
}
|
||||||
|
|
||||||
case DeckRoles::IsCustomZoneRole: {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
@ -346,13 +327,6 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
||||||
return false;
|
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);
|
beginRemoveRows(parent, row, row + count - 1);
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
AbstractDecklistNode *toDelete = node->takeAt(row);
|
AbstractDecklistNode *toDelete = node->takeAt(row);
|
||||||
|
|
@ -363,8 +337,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
||||||
}
|
}
|
||||||
endRemoveRows();
|
endRemoveRows();
|
||||||
|
|
||||||
// Empty criteria groups get pruned, but custom zones stay until explicitly deleted.
|
if (node->empty() && (node != root)) {
|
||||||
if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) {
|
|
||||||
removeRows(parent.row(), 1, parent.parent());
|
removeRows(parent.row(), 1, parent.parent());
|
||||||
} else {
|
} else {
|
||||||
emitRecursiveUpdates(parent);
|
emitRecursiveUpdates(parent);
|
||||||
|
|
@ -378,8 +351,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
||||||
|
|
||||||
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
|
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
|
||||||
{
|
{
|
||||||
// Group lookups must not resolve a mirrored custom zone that shares the name.
|
auto *newNode = dynamic_cast<InnerDecklistNode *>(parent->findChild(name));
|
||||||
auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name);
|
|
||||||
if (!newNode) {
|
if (!newNode) {
|
||||||
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
|
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
|
||||||
newNode = new InnerDecklistNode(name, parent);
|
newNode = new InnerDecklistNode(name, parent);
|
||||||
|
|
@ -393,44 +365,24 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
|
||||||
const QString &providerId,
|
const QString &providerId,
|
||||||
const QString &cardNumber) const
|
const QString &cardNumber) const
|
||||||
{
|
{
|
||||||
|
InnerDecklistNode *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
||||||
|
if (!zoneNode) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Board zone lookup: search the criteria groups, then the custom zones
|
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
|
||||||
// nested under the board.
|
InnerDecklistNode *groupNode = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(groupCriteria));
|
||||||
if (auto *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName))) {
|
if (!groupNode) {
|
||||||
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
|
return nullptr;
|
||||||
if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
|
|
||||||
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
|
|
||||||
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (auto *child : *zoneNode) {
|
|
||||||
if (!DeckListModelCustomZones::isCustomZone(child)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
auto *customZone = dynamic_cast<InnerDecklistNode *>(child);
|
|
||||||
if (!customZone) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
|
|
||||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Custom zone lookup by name (custom zone names are deck-unique).
|
return dynamic_cast<DecklistModelCardNode *>(
|
||||||
if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
|
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
||||||
return dynamic_cast<DecklistModelCardNode *>(
|
|
||||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
|
||||||
}
|
|
||||||
|
|
||||||
return nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QModelIndex DeckListModel::findCard(const QString &cardName,
|
QModelIndex DeckListModel::findCard(const QString &cardName,
|
||||||
|
|
@ -471,95 +423,29 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root);
|
||||||
|
|
||||||
CardInfoPtr cardInfo = card.getCardPtr();
|
CardInfoPtr cardInfo = card.getCardPtr();
|
||||||
PrintingInfo printingInfo = card.getPrinting();
|
PrintingInfo printingInfo = card.getPrinting();
|
||||||
|
|
||||||
InnerDecklistNode *cardParent = nullptr;
|
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||||
|
InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode);
|
||||||
|
|
||||||
auto *boardNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
const QModelIndex parentIndex = nodeToIndex(groupNode);
|
||||||
auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName);
|
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(groupNode->findCardChildByNameProviderIdAndNumber(
|
||||||
|
|
||||||
// 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<InnerDecklistNode *>(listRoot->at(i));
|
|
||||||
if (!boardZone) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
deckCardParent = dynamic_cast<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<DecklistModelCardNode *>(cardParent->findCardChildByNameProviderIdAndNumber(
|
|
||||||
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
|
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
|
||||||
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
|
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
|
||||||
|
|
||||||
bool cardNodeAdded = false;
|
bool cardNodeAdded = false;
|
||||||
if (!cardNode) {
|
if (!cardNode) {
|
||||||
// Determine the correct index
|
// Determine the correct index
|
||||||
int insertRow = findSortedInsertRow(cardParent, cardInfo);
|
int insertRow = findSortedInsertRow(groupNode, cardInfo);
|
||||||
int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow;
|
|
||||||
|
|
||||||
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName,
|
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName,
|
||||||
printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
|
printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
|
||||||
|
|
||||||
beginInsertRows(parentIndex, insertRow, insertRow);
|
beginInsertRows(parentIndex, insertRow, insertRow);
|
||||||
cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow);
|
cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow);
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
|
|
||||||
cardNodeAdded = true;
|
cardNodeAdded = true;
|
||||||
|
|
@ -690,41 +576,21 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
|
||||||
return createIndex(node->getParent()->indexOf(node), 0, node);
|
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<InnerDecklistNode *>(node->at(i))) {
|
|
||||||
sortShadowTree(subNode, order);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
|
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
|
||||||
{
|
{
|
||||||
// Sort children (custom zones always sorted after groups within a board) and
|
// Sort children of node and save the information needed to
|
||||||
// use the movement mapping to update the list of persistent indices.
|
// update the list of persistent indexes.
|
||||||
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||||
|
|
||||||
QModelIndexList from, to;
|
QModelIndexList from, to;
|
||||||
int columns = columnCount();
|
int columns = columnCount();
|
||||||
for (const auto &move : mapping) {
|
for (int i = sortResult.size() - 1; i >= 0; --i) {
|
||||||
const int preSortRow = move.first;
|
const int fromRow = sortResult[i].first;
|
||||||
const int finalRow = move.second;
|
const int toRow = sortResult[i].second;
|
||||||
AbstractDecklistNode *temp = node->at(finalRow);
|
AbstractDecklistNode *temp = node->at(toRow);
|
||||||
for (int j = 0; j < columns; ++j) {
|
for (int j = 0; j < columns; ++j) {
|
||||||
from << createIndex(preSortRow, j, temp);
|
from << createIndex(fromRow, j, temp);
|
||||||
to << createIndex(finalRow, j, temp);
|
to << createIndex(toRow, j, temp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changePersistentIndexList(from, to);
|
changePersistentIndexList(from, to);
|
||||||
|
|
@ -838,15 +704,6 @@ QList<QString> DeckListModel::getZones() const
|
||||||
return zones;
|
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)
|
static int maxAllowedForLegality(const FormatRules &format, const QString &legality)
|
||||||
{
|
{
|
||||||
for (const AllowedCount &c : format.allowedCounts) {
|
for (const AllowedCount &c : format.allowedCounts) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
#ifndef DECKLISTMODEL_H
|
#ifndef DECKLISTMODEL_H
|
||||||
#define 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/abstract_deck_list_card_node.h>
|
||||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||||
#include <QAbstractItemModel>
|
#include <QAbstractItemModel>
|
||||||
|
|
@ -32,8 +30,7 @@ enum
|
||||||
{
|
{
|
||||||
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
|
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
|
||||||
DepthRole, /**< Depth level within the deck's grouping hierarchy. */
|
DepthRole, /**< Depth level within the deck's grouping hierarchy. */
|
||||||
IsLegalRole, /**< Whether the card is legal in the current deck format. */
|
IsLegalRole /**< Whether the card is legal in the current deck format. */
|
||||||
IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */
|
|
||||||
};
|
};
|
||||||
} // namespace DeckRoles
|
} // namespace DeckRoles
|
||||||
|
|
||||||
|
|
@ -394,14 +391,6 @@ public:
|
||||||
*/
|
*/
|
||||||
[[nodiscard]] QList<QString> getZones() const;
|
[[nodiscard]] QList<QString> 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:
|
private:
|
||||||
QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */
|
QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */
|
||||||
InnerDecklistNode *root; /**< Root node of the model tree. */
|
InnerDecklistNode *root; /**< Root node of the model tree. */
|
||||||
|
|
@ -438,7 +427,6 @@ private:
|
||||||
void emitRecursiveUpdates(const QModelIndex &index);
|
void emitRecursiveUpdates(const QModelIndex &index);
|
||||||
|
|
||||||
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
|
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
|
||||||
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
|
|
||||||
|
|
||||||
template <typename T> T getNode(const QModelIndex &index) const
|
template <typename T> T getNode(const QModelIndex &index) const
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 <QHash>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
namespace DeckListModelCustomZones
|
|
||||||
{
|
|
||||||
|
|
||||||
bool isCustomZone(const AbstractDecklistNode *node)
|
|
||||||
{
|
|
||||||
return dynamic_cast<const DecklistModelSubZoneNode *>(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<DecklistCardNode *>(zone->at(k))) {
|
|
||||||
new DecklistModelCardNode(zoneCard, shadowZone);
|
|
||||||
} else if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(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<const InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(root->at(i));
|
|
||||||
if (!boardZone) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int j = 0; j < boardZone->size(); j++) {
|
|
||||||
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(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<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
|
|
||||||
{
|
|
||||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
|
||||||
|
|
||||||
QList<QPair<int, int>> 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<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
|
|
||||||
{
|
|
||||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
|
||||||
|
|
||||||
QVector<AbstractDecklistNode *> groups;
|
|
||||||
QVector<AbstractDecklistNode *> customZones;
|
|
||||||
QHash<AbstractDecklistNode *, int> 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<AbstractDecklistNode *> ordered = groups + customZones;
|
|
||||||
for (int i = 0; i < ordered.size(); ++i) {
|
|
||||||
node->replace(i, ordered[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
QList<QPair<int, int>> mapping;
|
|
||||||
mapping.reserve(ordered.size());
|
|
||||||
for (int i = 0; i < ordered.size(); ++i) {
|
|
||||||
mapping.append({preSortRowOf.value(ordered[i]), i});
|
|
||||||
}
|
|
||||||
return mapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
QList<QPair<int, int>> 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
|
|
||||||
|
|
@ -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 <QList>
|
|
||||||
#include <QPair>
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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<QPair<int, int>>
|
|
||||||
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
|
|
||||||
|
|
||||||
} // namespace DeckListModelCustomZones
|
|
||||||
|
|
||||||
#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H
|
|
||||||
|
|
@ -23,7 +23,6 @@ set(oracle_SOURCES
|
||||||
src/pages.cpp
|
src/pages.cpp
|
||||||
src/pagetemplates.cpp
|
src/pagetemplates.cpp
|
||||||
src/parsehelpers.cpp
|
src/parsehelpers.cpp
|
||||||
src/raw_json_scanner.cpp
|
|
||||||
../cockatrice/src/client/settings/cache_settings.cpp
|
../cockatrice/src/client/settings/cache_settings.cpp
|
||||||
../cockatrice/src/client/settings/card_counter_settings.cpp
|
../cockatrice/src/client/settings/card_counter_settings.cpp
|
||||||
../cockatrice/src/client/settings/shortcuts_settings.cpp
|
../cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||||
|
|
@ -113,8 +112,6 @@ qt6_add_executable(
|
||||||
MANUAL_FINALIZATION
|
MANUAL_FINALIZATION
|
||||||
)
|
)
|
||||||
|
|
||||||
target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
|
|
||||||
|
|
||||||
# ------------------------
|
# ------------------------
|
||||||
# Link libraries
|
# Link libraries
|
||||||
# ------------------------
|
# ------------------------
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QJsonParseError>
|
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
@ -45,23 +44,26 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s
|
||||||
return priority;
|
return priority;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OracleImporter::readSetsFromByteArray(QByteArray data)
|
bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
|
||||||
{
|
{
|
||||||
RawJson::ScanError error;
|
QJsonParseError error;
|
||||||
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(data, &error);
|
auto doc = QJsonDocument::fromJson(data, &error);
|
||||||
if (error.isError()) {
|
if (error.error != QJsonParseError::NoError) {
|
||||||
qDebug() << "error: RawJson::scanSetRanges():" << error.message;
|
qDebug() << "error: QJsonDocument::fromJson():" << error.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<SetToDownload> newSetList;
|
auto setsObj = doc.object().value("data").toObject();
|
||||||
newSetList.reserve(ranges.size());
|
|
||||||
|
|
||||||
for (const RawJson::SetRange &range : ranges) {
|
QList<SetToDownload> newSetList;
|
||||||
QString shortName = range.code.toUpper();
|
|
||||||
QString longName = range.name;
|
for (auto it = setsObj.constBegin(); it != setsObj.constEnd(); ++it) {
|
||||||
QString setType = range.type;
|
QJsonObject setObj = it.value().toObject();
|
||||||
QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate);
|
QString shortName = setObj.value("code").toString().toUpper();
|
||||||
|
QString longName = setObj.value("name").toString();
|
||||||
|
QJsonArray setCards = setObj.value("cards").toArray();
|
||||||
|
QString setType = setObj.value("type").toString();
|
||||||
|
QDate releaseDate = QDate::fromString(setObj.value("releaseDate").toString(), Qt::ISODate);
|
||||||
CardSet::Priority priority = getSetPriority(setType, shortName);
|
CardSet::Priority priority = getSetPriority(setType, shortName);
|
||||||
// capitalize set type
|
// capitalize set type
|
||||||
if (setType.length() > 0) {
|
if (setType.length() > 0) {
|
||||||
|
|
@ -81,9 +83,7 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data)
|
||||||
}
|
}
|
||||||
setType = setType.trimmed();
|
setType = setType.trimmed();
|
||||||
}
|
}
|
||||||
SetToDownload set(shortName, longName, priority, setType, releaseDate);
|
newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate));
|
||||||
set.setRawRange(range.dataRange);
|
|
||||||
newSetList.append(set);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::sort(newSetList.begin(), newSetList.end());
|
std::sort(newSetList.begin(), newSetList.end());
|
||||||
|
|
@ -92,7 +92,6 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
allSets = newSetList;
|
allSets = newSetList;
|
||||||
rawSetsData = std::move(data);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,16 +550,22 @@ int OracleImporter::startImport()
|
||||||
{
|
{
|
||||||
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
|
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
|
||||||
|
|
||||||
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
|
// Pre-allocate the cards hash to avoid rehashing during import. The hash
|
||||||
// distinct card names while raw ranges only count printings (AllPrintings
|
// is keyed by distinct card name rather than by printings: AllPrintings
|
||||||
// ~100k printings vs ~35k names), so this over-reserves somewhat; an exact
|
// ships ~100k printings for ~35k names, so reserving the printing count
|
||||||
// distinct-name count would require eagerly parsing, which the lazy reader
|
// would overallocate ~3x (against this stack's RAM goal). Collecting
|
||||||
// deliberately avoids. It's a capacity hint, so the overshoot is harmless.
|
// distinct names is cheap — one pass over the already-parsed name fields.
|
||||||
int estimatedCards = 0;
|
{
|
||||||
for (const SetToDownload &curSetToParse : allSets) {
|
QSet<QString> distinctNames;
|
||||||
estimatedCards += curSetToParse.getRawRange().cardCount;
|
for (const SetToDownload &curSetToParse : allSets) {
|
||||||
|
for (const QJsonValue &cardValue : curSetToParse.getCards()) {
|
||||||
|
distinctNames.insert(cardValue.toObject().value("name").toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cards.reserve(distinctNames.size());
|
||||||
|
// The set goes out of scope here, handing the ~35k name QStrings back
|
||||||
|
// to the allocator before the (memory-heavy) import loop starts.
|
||||||
}
|
}
|
||||||
cards.reserve(estimatedCards);
|
|
||||||
|
|
||||||
// add an empty set for tokens
|
// add an empty set for tokens
|
||||||
CardSetPtr tokenSet =
|
CardSetPtr tokenSet =
|
||||||
|
|
@ -573,44 +578,11 @@ int OracleImporter::startImport()
|
||||||
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
|
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
|
||||||
curSetToParse.getLongName(), curSetToParse.getSetType(),
|
curSetToParse.getLongName(), curSetToParse.getSetType(),
|
||||||
curSetToParse.getReleaseDate(), curSetToParse.getPriority());
|
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())) {
|
if (!sets.contains(newSet->getShortName())) {
|
||||||
sets.insert(newSet->getShortName(), newSet);
|
sets.insert(newSet->getShortName(), newSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QJsonArray setCards = setDoc.object().value("cards").toArray();
|
int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards());
|
||||||
int numCardsInSet = importCardsFromSet(newSet, setCards);
|
|
||||||
|
|
||||||
++setIndex;
|
++setIndex;
|
||||||
|
|
||||||
|
|
@ -633,7 +605,6 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr
|
||||||
void OracleImporter::releaseSetData()
|
void OracleImporter::releaseSetData()
|
||||||
{
|
{
|
||||||
allSets.clear();
|
allSets.clear();
|
||||||
rawSetsData.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void OracleImporter::clear()
|
void OracleImporter::clear()
|
||||||
|
|
@ -641,5 +612,4 @@ void OracleImporter::clear()
|
||||||
sets.clear();
|
sets.clear();
|
||||||
cards.clear();
|
cards.clear();
|
||||||
allSets.clear();
|
allSets.clear();
|
||||||
rawSetsData.clear();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
#ifndef ORACLEIMPORTER_H
|
#ifndef ORACLEIMPORTER_H
|
||||||
#define ORACLEIMPORTER_H
|
#define ORACLEIMPORTER_H
|
||||||
|
|
||||||
#include "raw_json_scanner.h"
|
|
||||||
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
|
|
@ -49,12 +46,10 @@ class SetToDownload
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
QString shortName, longName;
|
QString shortName, longName;
|
||||||
|
QJsonArray cards;
|
||||||
QDate releaseDate;
|
QDate releaseDate;
|
||||||
QString setType;
|
QString setType;
|
||||||
CardSet::Priority priority;
|
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:
|
public:
|
||||||
const QString &getShortName() const
|
const QString &getShortName() const
|
||||||
|
|
@ -65,6 +60,10 @@ public:
|
||||||
{
|
{
|
||||||
return longName;
|
return longName;
|
||||||
}
|
}
|
||||||
|
const QJsonArray &getCards() const
|
||||||
|
{
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
const QString &getSetType() const
|
const QString &getSetType() const
|
||||||
{
|
{
|
||||||
return setType;
|
return setType;
|
||||||
|
|
@ -77,23 +76,16 @@ public:
|
||||||
{
|
{
|
||||||
return priority;
|
return priority;
|
||||||
}
|
}
|
||||||
const RawJson::SetDataRange &getRawRange() const
|
|
||||||
{
|
|
||||||
return rawRange;
|
|
||||||
}
|
|
||||||
SetToDownload(QString _shortName,
|
SetToDownload(QString _shortName,
|
||||||
QString _longName,
|
QString _longName,
|
||||||
|
QJsonArray _cards,
|
||||||
CardSet::Priority _priority,
|
CardSet::Priority _priority,
|
||||||
QString _setType = QString(),
|
QString _setType = QString(),
|
||||||
const QDate &_releaseDate = QDate())
|
const QDate &_releaseDate = QDate())
|
||||||
: shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate),
|
: shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)),
|
||||||
setType(std::move(_setType)), priority(_priority)
|
releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
void setRawRange(const RawJson::SetDataRange &_rawRange)
|
|
||||||
{
|
|
||||||
rawRange = _rawRange;
|
|
||||||
}
|
|
||||||
bool operator<(const SetToDownload &set) const
|
bool operator<(const SetToDownload &set) const
|
||||||
{
|
{
|
||||||
return longName.compare(set.longName, Qt::CaseInsensitive) < 0;
|
return longName.compare(set.longName, Qt::CaseInsensitive) < 0;
|
||||||
|
|
@ -149,12 +141,6 @@ private:
|
||||||
|
|
||||||
QList<SetToDownload> allSets;
|
QList<SetToDownload> 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,
|
CardInfoPtr addCard(QString name,
|
||||||
const QString &text,
|
const QString &text,
|
||||||
bool isToken,
|
bool isToken,
|
||||||
|
|
@ -167,11 +153,7 @@ signals:
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit OracleImporter(QObject *parent = nullptr);
|
explicit OracleImporter(QObject *parent = nullptr);
|
||||||
/**
|
bool readSetsFromByteArray(const QByteArray &data);
|
||||||
* Scans the given JSON document for set metadata. Takes the data by value so
|
|
||||||
* the wizard can hand over its decompressed buffer without copying it.
|
|
||||||
*/
|
|
||||||
bool readSetsFromByteArray(QByteArray data);
|
|
||||||
int startImport();
|
int startImport();
|
||||||
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
|
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
|
||||||
int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList);
|
int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList);
|
||||||
|
|
@ -187,10 +169,6 @@ public:
|
||||||
{
|
{
|
||||||
return allSets;
|
return allSets;
|
||||||
}
|
}
|
||||||
const QByteArray &getRawSetsData() const
|
|
||||||
{
|
|
||||||
return rawSetsData;
|
|
||||||
}
|
|
||||||
void releaseSetData();
|
void releaseSetData();
|
||||||
void clear();
|
void clear();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,621 +0,0 @@
|
||||||
#include "raw_json_scanner.h"
|
|
||||||
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
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<size_t>(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<size_t>(static_cast<const char *>(quote) - p));
|
|
||||||
if (!backslash) {
|
|
||||||
p = static_cast<const char *>(quote) + 1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const char *b = static_cast<const char *>(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<size_t>(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 <typename F> 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<SetRange> scanSetRanges(const QByteArray &json, ScanError *error)
|
|
||||||
{
|
|
||||||
QList<SetRange> ranges;
|
|
||||||
if (error) {
|
|
||||||
*error = ScanError{};
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto fail = [&](const QString &message) -> QList<SetRange> {
|
|
||||||
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
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
#ifndef RAW_JSON_SCANNER_H
|
|
||||||
#define RAW_JSON_SCANNER_H
|
|
||||||
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QList>
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
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<SetRange> scanSetRanges(const QByteArray &json, ScanError *error = nullptr);
|
|
||||||
|
|
||||||
} // namespace RawJson
|
|
||||||
|
|
||||||
#endif // RAW_JSON_SCANNER_H
|
|
||||||
|
|
@ -95,8 +95,6 @@ set(DESKTOPDIR
|
||||||
# Build servatrice binary and link it
|
# Build servatrice binary and link it
|
||||||
add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES})
|
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")
|
if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD")
|
||||||
target_link_libraries(
|
target_link_libraries(
|
||||||
servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES}
|
servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,6 @@ target_link_libraries(
|
||||||
|
|
||||||
add_subdirectory(card_zone_algorithms)
|
add_subdirectory(card_zone_algorithms)
|
||||||
add_subdirectory(carddatabase)
|
add_subdirectory(carddatabase)
|
||||||
add_subdirectory(deck_list_model)
|
|
||||||
add_subdirectory(deck_list_zones)
|
add_subdirectory(deck_list_zones)
|
||||||
add_subdirectory(loading_from_clipboard)
|
add_subdirectory(loading_from_clipboard)
|
||||||
add_subdirectory(movecard_tests)
|
add_subdirectory(movecard_tests)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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 <gtest/gtest.h>
|
|
||||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
|
||||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
|
||||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
|
||||||
|
|
||||||
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<DecklistModelSubZoneNode *>(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<DecklistModelCardNode *>(shadowZone->at(0));
|
|
||||||
auto *shadowCard2 = dynamic_cast<DecklistModelCardNode *>(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<DecklistModelSubZoneNode *>(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<DecklistModelCardNode *>(shadowZone->at(0));
|
|
||||||
auto *shadowCard2 = dynamic_cast<DecklistModelCardNode *>(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<AbstractDecklistNode *> 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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
#include <gtest/gtest.h>
|
|
||||||
#include <libcockatrice/card/card_info.h>
|
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
|
||||||
#include <libcockatrice/card/game_specific_terms.h>
|
|
||||||
#include <libcockatrice/card/printing/exact_card.h>
|
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
|
||||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
|
||||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
|
||||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
|
||||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
|
||||||
|
|
||||||
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<DeckList> 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<DeckList> 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<DeckList> 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<DeckList> 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<InnerDecklistNode *>(listRoot->at(i))) {
|
|
||||||
topLevelRemoval |= zone->getName() == "Removal";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EXPECT_FALSE(topLevelRemoval);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel)
|
|
||||||
{
|
|
||||||
QSharedPointer<DeckList> 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<DeckList> 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<DeckList> 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<DeckList> 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<DeckList> 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<DeckList> 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<DeckList> 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();
|
|
||||||
}
|
|
||||||
|
|
@ -213,42 +213,6 @@ TEST(DeckListZones, MoveCustomZoneMovesCards)
|
||||||
EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board"));
|
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)
|
TEST(DeckListZones, RemoveCustomZoneRemovesCards)
|
||||||
{
|
{
|
||||||
DeckList deck;
|
DeckList deck;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ add_test(NAME parse_cipt_test COMMAND parse_cipt_test)
|
||||||
# Oracle importer unit tests
|
# Oracle importer unit tests
|
||||||
add_executable(
|
add_executable(
|
||||||
oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
|
oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
|
||||||
../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp
|
oracle_importer_test.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
if(NOT GTEST_FOUND)
|
if(NOT GTEST_FOUND)
|
||||||
|
|
@ -25,33 +25,10 @@ target_link_libraries(
|
||||||
|
|
||||||
add_test(NAME oracle_importer_test COMMAND oracle_importer_test)
|
add_test(NAME oracle_importer_test COMMAND oracle_importer_test)
|
||||||
|
|
||||||
# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark)
|
# Oracle importer benchmark tests (manual, not run in CI)
|
||||||
# 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(
|
add_executable(
|
||||||
oracle_importer_benchmark_test
|
oracle_importer_benchmark_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp
|
||||||
${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
|
../../oracle/src/parsehelpers.cpp oracle_importer_benchmark_test.cpp
|
||||||
../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if(NOT GTEST_FOUND)
|
if(NOT GTEST_FOUND)
|
||||||
|
|
@ -59,11 +36,6 @@ if(NOT GTEST_FOUND)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
target_link_libraries(
|
target_link_libraries(
|
||||||
oracle_importer_benchmark_test
|
oracle_importer_benchmark_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES}
|
||||||
libcockatrice_card
|
|
||||||
libcockatrice_interfaces
|
|
||||||
Threads::Threads
|
|
||||||
${GTEST_BOTH_LIBRARIES}
|
|
||||||
${TEST_QT_MODULES}
|
${TEST_QT_MODULES}
|
||||||
${_ORACLE_BENCH_EXTRA_LIBRARIES}
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,13 @@
|
||||||
#include "../../oracle/src/oracleimporter.h"
|
#include "../../oracle/src/oracleimporter.h"
|
||||||
|
|
||||||
#include "gtest/gtest.h"
|
#include "gtest/gtest.h"
|
||||||
#include <QBuffer>
|
|
||||||
#include <QCoreApplication>
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
#include <QEventLoop>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QNetworkReply>
|
|
||||||
#include <QNetworkRequest>
|
|
||||||
#include <QTimer>
|
|
||||||
#include <QUrl>
|
|
||||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||||
|
|
||||||
#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 <mach/mach.h>
|
|
||||||
#include <sys/resource.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set
|
// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set
|
||||||
static QByteArray buildSyntheticData(int numSets, int cardsPerSet)
|
static QByteArray buildSyntheticData(int numSets, int cardsPerSet)
|
||||||
{
|
{
|
||||||
|
|
@ -157,10 +137,9 @@ TEST(OracleBenchmark, ParseJsonThroughput)
|
||||||
|
|
||||||
for (int i = 0; i < iterations; ++i) {
|
for (int i = 0; i < iterations; ++i) {
|
||||||
OracleImporter importer;
|
OracleImporter importer;
|
||||||
QByteArray source = data;
|
|
||||||
QElapsedTimer timer;
|
QElapsedTimer timer;
|
||||||
timer.start();
|
timer.start();
|
||||||
bool ok = importer.readSetsFromByteArray(std::move(source));
|
bool ok = importer.readSetsFromByteArray(data);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
totalMs += timer.elapsed();
|
totalMs += timer.elapsed();
|
||||||
}
|
}
|
||||||
|
|
@ -278,301 +257,8 @@ TEST(OracleBenchmark, ImportCardsWithColors)
|
||||||
.arg(ms > 0 ? static_cast<double>(count) / ms * 1000.0 : 0.0, 0, 'f', 0);
|
.arg(ms > 0 ? static_cast<double>(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<QByteArray> 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<task_info_t>(&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<QByteArray *>(&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<QByteArray *>(&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)
|
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);
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
return RUN_ALL_TESTS();
|
return RUN_ALL_TESTS();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -545,202 +545,6 @@ TEST_F(OracleImporterTest, ApostropheNormalized)
|
||||||
ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity"));
|
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<RawJson::SetRange> 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<RawJson::SetRange> 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<QByteArray> 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<QByteArray> 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<RawJson::SetRange> 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)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
::testing::InitGoogleTest(&argc, argv);
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue